Merge "Adding checkbox to annotations API to toggle annotations on/off"
diff --git a/.gitignore b/.gitignore
index 0e954ce..5fdc85b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,7 +20,6 @@
 /.settings/org.maven.ide.eclipse.prefs
 /bazel-*
 /bin/
-/buck-out
 /eclipse-out
 /extras
 /gerrit-package-plugins
@@ -30,4 +29,4 @@
 /plugins/cookbook-plugin/
 /test_site
 /tools/format
-/.vscode
\ No newline at end of file
+/.vscode
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index a4f03d5..cc03334 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -1422,6 +1422,177 @@
 link:cmd-show-queue.html[look at the Gerrit task queue via ssh].
 
 
+[[reference]]
+== Permission evaluation reference
+
+Permission evaluation is expressed in the following concepts:
+
+* PermisssionRule: a single combination of {ALLOW, DENY, BLOCK} and
+group, and optionally a vote range and an 'exclusive' bit.
+
+* Permission: groups PermissionRule by permission name. All
+PermissionRules for same access type (eg. "read", "push") are grouped
+into a Permission implicitly. The exclusive bit lives here.
+
+* AccessSection: ties a list of Permissions to a single ref pattern.
+Each AccessSection comes from a single project.
+
+
+
+Here is how these play out in a link:config-project-config.html[project.config] file:
+
+----
+  # An AccessSection
+  [access "refs/heads/stable/*"]
+     exclusiveGroupPermissions = create
+
+     # Each of the following lines corresponds to a PermissionRule
+     # The next two PermissionRule together form the "read" Permission
+     read = group Administrators
+     read = group Registered Users
+
+     # A Permission with a block and block-override
+     create = block group Registered Users
+     create = group Project Owners
+
+     # A Permission and PermissionRule for a label
+     label-Code-Review = -2..+2 group Project Owners
+----
+
+=== Ref permissions
+
+Access to refs can be blocked, allowed or denied.
+
+==== BLOCK
+
+For blocking access, all rules marked BLOCK are tested, and if one
+such rule matches, the user is denied access.
+
+The rules are ordered by inheritance, starting from All-Projects down.
+Within a project, more specific ref patterns come first. The downward
+ordering lets administrators enforce access rules across all projects
+in a site.
+
+BLOCK rules can have exceptions defined on the same project (eg. BLOCK
+anonymous users, ie. everyone, but make an exception for Admin users),
+either by:
+
+1. adding ALLOW PermissionRules in the same Permission. This implies
+they apply to the same ref pattern.
+
+2. adding an ALLOW Permission in the same project with a more specific
+ref pattern, but marked "exclusive". This allows them to apply to
+different ref patterns.
+
+Such additions not only bypass BLOCK rules, but they will also grant
+permissions when they are processed in the ALLOW/DENY processing, as
+described in the next subsection.
+
+==== ALLOW
+
+For allowing access, all ALLOW/DENY rules that might apply to a ref
+are tested until one granting access is found, or until either an
+"exclusive" rule ends the search, or all rules have been tested.
+
+The rules are ordered from specific ref patterns to general patterns,
+and for equally specific patterns, from originating project up to
+All-Projects.
+
+This ordering lets project owners apply permissions specific to their
+project, overwriting the site defaults specified in All-Projects.
+
+==== DENY
+
+DENY is processed together with ALLOW.
+
+As said, during ALLOW/DENY processing, rules are tried out one by one.
+For each (permission, ref-pattern, group) only a single rule
+ALLOW/DENY rule is picked. If that first rule is a DENY rule, any
+following ALLOW rules for the same (permission, ref-pattern, group)
+will be ignored, canceling out their effect.
+
+DENY is confusing because it only works on a specific (ref-pattern,
+group) pair. The parent project can undo the effect of a DENY rule by
+introducing an extra rule which features a more general ref pattern or
+a different group.
+
+==== DENY/ALLOW example
+
+Consider the ref "refs/a" and the following configuration:
+----
+
+child-project: project.config
+    [access "refs/a"]
+      read = deny group A
+
+All-Projects: project.config
+    [access "refs/a"]
+      read = group A      # ALLOW
+    [access "refs/*"]
+      read = group B      # ALLOW
+----
+
+When determining access, first "read = DENY group A" on "refs/a" is
+encountered. The following rule to consider is "ALLOW read group A" on
+"refs/a". The latter rule applies to the same (permission,
+ref-pattern, group) tuple, so it it is ignored.
+
+The DENY rule does not affect the last rule for "refs/*", since that
+has a different ref pattern and a different group. If group B is a
+superset of group A, the last rule will still grant group A access to
+"refs/a".
+
+
+==== Double use of exclusive
+
+An 'exclusive' permission is evaluated both during BLOCK processing
+and during ALLOW/DENY: when looking BLOCK, 'exclusive' stops the
+search downward, while the same permission in the ALLOW/DENY
+processing will stop looking upward for further rule matches
+
+==== Force permission
+
+The 'force' setting may be set on ALLOW and BLOCK rules. In the case
+of ALLOW, the 'force' option makes the permission stronger (allowing
+both forced and unforced actions). For BLOCK, the 'force' option makes
+it weaker (the BLOCK with 'force' only blocks forced actions).
+
+
+=== Labels
+
+Labels use the same mechanism, with the following observations:
+
+* The 'force' setting has no effect on label ranges.
+
+* BLOCK specifies the values that a group cannot vote, eg.
+----
+  label-Code-Review = block -2..+2 group Anonymous Users
+----
+prevents all users from voting -2 or +2.
+
+* DENY works for votes too, with the same caveats
+
+* The blocked vote range is the union of the all the blocked vote
+ranges across projects, so in
+----
+All-Projects: project.config
+     label-Code-Review = block -2..+1 group A
+
+Child-Project: project-config
+     label-Code-Review = block -1..+2 group A
+----
+members of group A cannot vote at all in the Child-Project.
+
+
+* The allowed vote range is the union of vote ranges allowed by all of
+the ALLOW rules. For example, in
+----
+     label-Code-Review = -2..+1 group A
+     label-Code-Review = -1..+2 group B
+----
+a user that is both in A and B can vote -2..2.
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/cmd-review.txt b/Documentation/cmd-review.txt
index 121fcad..59ed6ff 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -120,9 +120,10 @@
 	Votes that are not permitted for the user are silently ignored.
 
 --label::
-	Set a label by name to the value 'N'.  Invalid votes (invalid label
-	or invalid value) and votes that are not permitted for the user are
-	silently ignored.
+	Set a label by name to the value 'N'. The ability to vote on all specified
+	labels is required. If the vote is invalid (invalid label or invalid name),
+	the vote is not permitted for the user, or the vote is on an outdated or
+	closed patch set, return an error instead of silently discarding the vote.
 
 --tag::
 -t::
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 38c3e0f..1b6d116 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -147,17 +147,6 @@
 +
 By default 1.
 
-[[audit]]
-=== Section audit
-
-[[audit.maskSensitiveData]]audit.maskSensitiveData::
-+
-If true, command parameters marked as sensitive are masked in audit logs.
-+
-This option only affects audit. Other means of logging will always be masked.
-+
-By default `false`.
-
 [[auth]]
 === Section auth
 
@@ -1137,13 +1126,6 @@
 +
 Default is true.
 
-[[change.allowDrafts]]change.allowDrafts::
-+
-Allow drafts workflow. If set to false, drafts cannot be created,
-deleted or published.
-+
-Default is true.
-
 [[change.api.allowedIdentifier]]change.api.allowedIdentifier::
 +
 Change identifier(s) that are allowed on the API. See
@@ -1311,39 +1293,16 @@
 
 [[changeCleanup.startTime]]changeCleanup.startTime::
 +
-Start time to define the first execution of the change cleanups.
-If the configured `'changeCleanup.interval'` is shorter than
-`'changeCleanup.startTime - now'` the start time will be preponed by
-the maximum integral multiple of `'changeCleanup.interval'` so that the
-start time is still in the future.
-+
-----
-<day of week> <hours>:<minutes>
-or
-<hours>:<minutes>
-
-<day of week> : Mon, Tue, Wed, Thu, Fri, Sat, Sun
-<hours>       : 00-23
-<minutes>     : 0-59
-----
-
+The link:#schedule-configuration-startTime[start time] for running
+change cleanups.
 
 [[changeCleanup.interval]]changeCleanup.interval::
 +
-Interval for periodic repetition of triggering the change cleanups.
-The interval must be larger than zero. The following suffixes are supported
-to define the time unit for the interval:
-+
-* `s, sec, second, seconds`
-* `m, min, minute, minutes`
-* `h, hr, hour, hours`
-* `d, day, days`
-* `w, week, weeks` (`1 week` is treated as `7 days`)
-* `mon, month, months` (`1 month` is treated as `30 days`)
-* `y, year, years` (`1 year` is treated as `365 days`)
+The link:#schedule-configuration-interval[interval] for running
+change cleanups.
 
-link:#schedule-examples[Schedule examples] can be found in the
-link:#gc[gc] section.
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
 
 [[commentlink]]
 === Section commentlink
@@ -2013,59 +1972,16 @@
 
 [[gc.startTime]]gc.startTime::
 +
-Start time to define the first execution of the git garbage collection.
-If the configured `'gc.interval'` is shorter than `'gc.startTime - now'`
-the start time will be preponed by the maximum integral multiple of
-`'gc.interval'` so that the start time is still in the future.
-+
-----
-<day of week> <hours>:<minutes>
-or
-<hours>:<minutes>
-
-<day of week> : Mon, Tue, Wed, Thu, Fri, Sat, Sun
-<hours>       : 00-23
-<minutes>     : 0-59
-----
-
+The link:#schedule-configuration-startTime[start time] for running the
+git garbage collection.
 
 [[gc.interval]]gc.interval::
 +
-Interval for periodic repetition of triggering the git garbage collection.
-The interval must be larger than zero. The following suffixes are supported
-to define the time unit for the interval:
-+
-* `s, sec, second, seconds`
-* `m, min, minute, minutes`
-* `h, hr, hour, hours`
-* `d, day, days`
-* `w, week, weeks` (`1 week` is treated as `7 days`)
-* `mon, month, months` (`1 month` is treated as `30 days`)
-* `y, year, years` (`1 year` is treated as `365 days`)
+The link:#schedule-configuration-interval[interval] for running the
+git garbage collection.
 
-[[schedule-examples]]
-Examples::
-+
-----
-gc.startTime = Fri 10:30
-gc.interval  = 2 day
-----
-+
-Assuming the server is started on Mon 7:00 -> `'startTime - now = 4 days 3:30 hours'`.
-This is larger than the interval hence prepone the start time
-by the maximum integral multiple of the interval so that start
-time is still in the future, i.e. prepone by 4 days. This yields
-a start time of Mon 10:30, next executions are Wed 10:30, Fri 10:30
-etc.
-+
-----
-gc.startTime = 6:00
-gc.interval = 1 day
-----
-+
-Assuming the server is started on Mon 7:00 this yields the first run on next Tuesday
-at 6:00 and a repetition interval of 1 day.
-
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
 
 [[gerrit]]
 === Section gerrit
@@ -2833,6 +2749,62 @@
 +
 Defaults to true.
 
+[[index.scheduledIndexer]]
+==== Subsection index.scheduledIndexer
+
+This section configures periodic indexing. Periodic indexing is
+intended to run only on slaves and only updates the group index.
+Replication to slaves happens on Git level so that Gerrit is not aware
+of incoming replication events. But slaves need an updated group index
+to resolve memberships of users for ACL validation. To keep the group
+index in slaves up-to-date the Gerrit slave periodically scans the
+group refs in the All-Users repository to reindex groups if they are
+stale.
+
+The scheduled reindexer is not able to detect group deletions that
+happened while the slave was offline, but since group deletions are not
+supported this should never happen. If nevertheless groups refs were
+deleted while a slave was offline a full offline link:pgm-reindex.html[
+reindex] must be performed.
+
+This section is only used if Gerrit runs in slave mode, otherwise it is
+ignored.
+
+[[index.scheduledIndexer.runOnStartup]]index.scheduledIndexer.runOnStartup::
++
+Whether the scheduled indexer should run once immediately on startup.
+If set to `true` the slave startup is blocked until all stale groups
+were reindexed. Enabling this allows to prevent that slaves that were
+offline for a longer period of time run with outdated group information
+until the first scheduled indexing is done.
++
+Defaults to `true`.
+
+[[index.scheduledIndexer.enabled]]index.scheduledIndexer.enabled::
++
+Whether the scheduled indexer is enabled. If the scheduled indexer is
+disabled you must implement other means to keep the group index for the
+slave up-to-date (e.g. by using ElasticSearch for the indexes).
++
+Defaults to `true`.
+
+[[index.scheduledIndexer.startTime]]index.scheduledIndexer.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+the scheduled indexer.
++
+Defaults to `00:00`.
+
+[[index.scheduledIndexer.interval]]index.scheduledIndexer.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+the scheduled indexer.
++
+Defaults to `5m`.
+
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
+
 ==== Lucene configuration
 
 Open and closed changes are indexed in separate indexes named
@@ -4737,34 +4709,16 @@
 
 [[accountDeactivation.startTime]]accountDeactivation.startTime::
 +
-Start time to define the first execution of account deactivations.
-If the configured `'accountDeactivation.interval'` is shorter than `'accountDeactivation.startTime - now'`
-the start time will be preponed by the maximum integral multiple of
-`'accountDeactivation.interval'` so that the start time is still in the future.
-+
-----
-<day of week> <hours>:<minutes>
-or
-<hours>:<minutes>
-
-<day of week> : Mon, Tue, Wed, Thu, Fri, Sat, Sun
-<hours>       : 00-23
-<minutes>     : 0-59
-----
+The link:#schedule-configuration-startTime[start time] for running
+account deactivations.
 
 [[accountDeactivation.interval]]accountDeactivation.interval::
 +
-Interval for periodic repetition of triggering account deactivation sweeps.
-The interval must be larger than zero. The following suffixes are supported
-to define the time unit for the interval:
-+
-* `s, sec, second, seconds`
-* `m, min, minute, minutes`
-* `h, hr, hour, hours`
-* `d, day, days`
-* `w, week, weeks` (`1 week` is treated as `7 days`)
-* `mon, month, months` (`1 month` is treated as `30 days`)
-* `y, year, years` (`1 year` is treated as `365 days`)
+The link:#schedule-configuration-interval[interval] for running
+account deactivations.
+
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
 
 [[urlAlias]]
 === Section urlAlias
@@ -4872,6 +4826,89 @@
 +
 By default "Name of user not set" is used.
 
+[[schedule-configuration]]
+=== Schedule Configuration
+
+Schedule configurations are used for running periodic background jobs.
+
+A schedule configuration consists of two parameters:
+
+[[schedule-configuration-interval]]
+* `interval`:
+Interval for running the periodic background job. The interval must be
+larger than zero. The following suffixes are supported to define the
+time unit for the interval:
+** `s`, `sec`, `second`, `seconds`
+** `m`, `min`, `minute`, `minutes`
+** `h`, `hr`, `hour`, `hours`
+** `d`, `day`, `days`
+** `w`, `week`, `weeks` (`1 week` is treated as `7 days`)
+** `mon`, `month`, `months` (`1 month` is treated as `30 days`)
+** `y`, `year`, `years` (`1 year` is treated as `365 days`)
+
+[[schedule-configuration-startTime]]
+* `startTime`:
+The start time defines the first execution of the periodic background
+job. If the configured `interval` is shorter than `startTime - now` the
+start time will be preponed by the maximum integral multiple of
+`interval` so that the start time is still in the future. `startTime`
+must have one of the following formats:
+
+** `<day of week> <hours>:<minutes>`
+** `<hours>:<minutes>`
+
++
+The placeholders can have the following values:
+
+*** `<day of week>`:
+`Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat`, `Sun`
+*** `<hours>`:
+`00`-`23`
+*** `<minutes>`:
+`00`-`59`
+
++
+The time zone cannot be specified but is always the system default
+time zone.
+
+The section (and optionally the subsection) in which the `interval` and
+`startTime` keys must be set depends on the background job for which a
+schedule should be configured. E.g. for the change cleanup job the keys
+must be set in the link:#changeCleanup[changeCleanup] section:
+
+----
+  [changeCleanup]
+    startTime = Fri 10:30
+    interval  = 2 days
+----
+
+[[schedule-configuration-examples]]
+Examples for a schedule configuration:
+
+* Example 1:
++
+----
+  startTime = Fri 10:30
+  interval  = 2 days
+----
++
+Assuming that the server is started on `Mon 07:00` then
+`startTime - now` is `4 days 3:30 hours`. This is larger than the
+interval hence the start time is preponed by the maximum integral
+multiple of the interval so that start time is still in the future,
+i.e. preponed by 4 days. This yields a start time of `Mon 10:30`, next
+executions are `Wed 10:30`, `Fri 10:30`. etc.
+
+* Example 2:
++
+----
+  startTime = 06:00
+  interval = 1 day
+----
++
+Assuming that the server is started on `Mon 07:00` then this yields the
+first run on Tuesday at 06:00 and a repetition interval of 1 day.
+
 [[secure.config]]
 == File `etc/secure.config`
 
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index 16a3a93..185feb4 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -39,6 +39,14 @@
 The core plugins are developed and maintained by the Gerrit maintainers
 and the Gerrit community.
 
+[[codemirror-editor]]
+=== codemirror-editor
+
+CodeMirror plugin for polygerrit.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/codemirror-editor[
+Project] |
+
 [[commit-message-length-validator]]
 === commit-message-length-validator
 
@@ -53,16 +61,6 @@
 link:https://gerrit.googlesource.com/plugins/commit-message-length-validator/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
-[[cookbook-plugin]]
-=== cookbook-plugin
-
-Sample plugin to demonstrate features of Gerrit's plugin API.
-
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/cookbook-plugin[
-Project] |
-link:https://gerrit.googlesource.com/plugins/cookbook-plugin/+doc/master/src/main/resources/Documentation/about.md[
-Documentation]
-
 [[download-commands]]
 === download-commands
 
@@ -347,6 +345,16 @@
 link:https://gerrit.googlesource.com/plugins/its-jira/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
+[[its-phabricator]]
+==== its-phabricator
+
+Plugin to integrate with Phabricator.
+
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/its-phabricator[
+Project] |
+link:https://gerrit.googlesource.com/plugins/its-phabricator/+doc/master/src/main/resources/Documentation/config.md[
+Configuration]
+
 [[its-rtc]]
 ==== its-rtc
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index b482de1..f6cdc06 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -363,18 +363,14 @@
 === Cleaning The download cache
 
 The cache for downloaded artifacts is located in
-`~/.gerritcodereview/buck-cache/downloaded-artifacts`.
+`~/.gerritcodereview/bazel-cache/downloaded-artifacts`.
 
 If you really do need to clean the download cache manually, then:
 
 ----
- rm -rf ~/.gerritcodereview/buck-cache/downloaded-artifacts
+ rm -rf ~/.gerritcodereview/bazel-cache/downloaded-artifacts
 ----
 
-[NOTE] When building with Bazel the artifacts are still cached in
-`~/.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
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt
index 5dacd71..072c22c 100644
--- a/Documentation/dev-build-plugins.txt
+++ b/Documentation/dev-build-plugins.txt
@@ -121,11 +121,24 @@
 ]
 ----
 
+If the plugin(s) being bundled in the release have external dependencies, include them
+in `plugins/external_plugin_deps`. You should alias `external_plugin_deps()` so it
+can be imported for multiple plugins. For example:
+
+----
+load(":my-plugin/external_plugin_deps.bzl", my_plugin="external_plugin_deps")
+load(":my-other-plugin/external_plugin_deps.bzl", my_other_plugin="external_plugin_deps")
+
+def external_plugin_deps():
+  my_plugin()
+  my_other_plugin()
+----
+
 [NOTE]
-Since `tools/bzl/plugins.bzl` is part of Gerrit's source code and the version
-of the war is based on the state of the git repository that is built; you should
-commit this change before building, otherwise the version will be marked as
-'dirty'.
+Since `tools/bzl/plugins.bzl` and `plugins/external_plugin_deps.bzl` are part of
+Gerrit's source code and the version of the war is based on the state of the git
+repository that is built; you should commit this change before building, otherwise
+the version will be marked as 'dirty'.
 
 == Bazel standalone driven
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 4a64e68..90b8521 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2659,15 +2659,18 @@
 Gerrit provides an extension point that enables Plugins to rank
 the list of reviewer suggestion a user receives upon clicking "Add Reviewer" on
 the change screen.
+
 Gerrit supports both a default suggestion that appears when the user has not yet
 typed anything and a filtered suggestion that is shown as the user starts
 typing.
-Plugins receive a candidate list and can return a Set of suggested reviewers
-containing the Account.Id and a score for each reviewer.
-The candidate list is non-binding and plugins can choose to return reviewers not
-initially contained in the candidate list.
-Server administrators can configure the overall weight of each plugin using the
-weight config parameter on [addreviewer "<pluginName-exportName>"].
+
+Plugins receive a candidate list and can return a `Set` of suggested reviewers
+containing the `Account.Id` and a score for each reviewer. The candidate list is
+non-binding and plugins can choose to return reviewers not initially contained in
+the candidate list.
+
+Server administrators can configure the overall weight of each plugin by setting
+the `addreviewer.pluginName-exportName.weight` value in `gerrit.config`.
 
 [source, java]
 ----
@@ -2706,7 +2709,7 @@
 import com.google.gerrit.server.mail.receive.MailMessage;
 
 public class MyPlugin implements MailFilter {
-  boolean shouldProcessMessage(MailMessage message) {
+  public boolean shouldProcessMessage(MailMessage message) {
     // Implement your filter logic here
     return true;
   }
diff --git a/Documentation/dev-polygerrit.txt b/Documentation/dev-polygerrit.txt
index 7898ae9..79049fc 100644
--- a/Documentation/dev-polygerrit.txt
+++ b/Documentation/dev-polygerrit.txt
@@ -1,13 +1,7 @@
 = PolyGerrit - GUI
 
 [IMPORTANT]
-PolyGerrit is still a beta feature...
-
-Missing features in PolyGerrit:
-
-- Inline Edit
-
-- And many more features missing.
+PolyGerrit is still a beta feature. Some features may be missing.
 
 == Configuring
 
diff --git a/Documentation/dev-release-subproject.txt b/Documentation/dev-release-subproject.txt
index fcafea5..4886849 100644
--- a/Documentation/dev-release-subproject.txt
+++ b/Documentation/dev-release-subproject.txt
@@ -32,7 +32,7 @@
 ----
 
 * Change the `id`, `bin_sha1`, and `src_sha1` values in the `maven_jar`
-for the subproject in `/lib/BUCK` to the `SNAPSHOT` version.
+for the subproject in `/WORKSPACE` to the `SNAPSHOT` version.
 +
 When Gerrit gets released, a release of the subproject has to be done
 and Gerrit has to reference the released subproject version.
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index 28066bc..fd2bef37 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -104,6 +104,11 @@
 Once started, it is safe to cancel and restart the migration process, or to
 switch to the online process.
 
+[NOTE]
+Migration requires a heap size comparable to running a Gerrit server. If you
+normally run `gerrit.war daemon` with an `-Xmx` flag, pass that to the migration
+tool as well.
+
 *Advantages*
 
 * Much faster than online; can use all available CPUs, since no live traffic
diff --git a/Documentation/pgm-MigrateAccountPatchReviewDb.txt b/Documentation/pgm-MigrateAccountPatchReviewDb.txt
index 5718a8a..c8ab193 100644
--- a/Documentation/pgm-MigrateAccountPatchReviewDb.txt
+++ b/Documentation/pgm-MigrateAccountPatchReviewDb.txt
@@ -27,6 +27,12 @@
 * Migrate data using this command
 * Start Gerrit
 
+[NOTE]
+When using MySQL, the file_name column length in the account_patch_reviews table will be shortened
+from the standard 4096 characters down to 255 characters. This is due to a
+link:https://dev.mysql.com/doc/refman/5.7/en/innodb-restrictions.html[MySQL limitation]
+on the max size of 767 bytes for each column in an index.
+
 == OPTIONS
 
 -d::
diff --git a/Documentation/prolog-change-facts.txt b/Documentation/prolog-change-facts.txt
index a62e0c3..2fb13e9 100644
--- a/Documentation/prolog-change-facts.txt
+++ b/Documentation/prolog-change-facts.txt
@@ -51,13 +51,6 @@
 |`commit_stats/3`   |`commit_stats(5,20,50).`
     |Number of files modified, number of insertions and the number of deletions.
 
-.4+|`current_user/1`  |`current_user(user(1000000)).`
-    .4+|Current user as one of the four given possibilities
-
-                      |`current_user(user(anonymous)).`
-                      |`current_user(user(peer_daemon)).`
-                      |`current_user(user(replication)).`
-
 |`pure_revert/1`     |`pure_revert(1).`
     |link:rest-api-changes.html#get-pure-revert[Pure revert] as integer atom (1 if
         the change is a pure revert, 0 otherwise)
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 7561286..386e2d6 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -977,30 +977,7 @@
 add_apprentice_master(S, S).
 ----
 
-=== Example 15: Only allow Author to submit change
-This example adds a new needed category `Only-Author-Can-Submit` for any user
-that is not the author of the patch. This effectively blocks all users except
-the author from submitting the change. This could result in an impossible
-situation if the author does not have permissions for submitting the change.
-
-`rules.pl`
-[source,prolog]
-----
-submit_rule(S) :-
-    gerrit:default_submit(In),
-    In =.. [submit | Ls],
-    only_allow_author_to_submit(Ls, R),
-    S =.. [submit | R].
-
-only_allow_author_to_submit(S, S) :-
-    gerrit:commit_author(Id),
-    gerrit:current_user(Id),
-    !.
-
-only_allow_author_to_submit(S1, [label('Only-Author-Can-Submit', need(_)) | S1]).
-----
-
-=== Example 16: Make change submittable if all comments have been resolved
+=== Example 15: Make change submittable if all comments have been resolved
 In this example we will use the `unresolved_comments_count` fact about a
 change. Our goal is to block the submission of any change with some
 unresolved comments. Basically, it can be achieved by the following rules:
@@ -1050,7 +1027,7 @@
 indicate to the user that all the comments have to be resolved for the
 change to become submittable.
 
-=== Example 17: Make change submittable if it is a pure revert
+=== Example 16: Make change submittable if it is a pure revert
 In this example we will use the `pure_revert` fact about a
 change. Our goal is to block the submission of any change that is not a
 pure revert. Basically, it can be achieved by the following rules:
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 5a49f38..65a15ca 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -402,10 +402,11 @@
 |`config_visible`     |not set if `false`|
 Whether the calling user can see the `refs/meta/config` branch of the
 project.
-|`groups`            |A map of group UUID to
-link:rest-api-groups.html#group-info[GroupInfo] objects, describing
-the group UUIDs used in the `local` map. Groups that are not visible
-are omitted from the `groups` map.
+|`groups`            ||A map of group UUID to
+link:rest-api-groups.html#group-info[GroupInfo] objects, with names and
+URLs for the group UUIDs used in the `local` map.
+This will include names for groups that might
+be invisible to the caller.
 |`configWebLinks`    ||
 A list of URLs that display the history of the configuration file
 governing this project's access rights.
diff --git a/WORKSPACE b/WORKSPACE
index 6044953..ac0ffd2 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -11,13 +11,12 @@
     urls = ["https://github.com/bazelbuild/bazel-skylib/archive/2169ae1c374aab4a09aa90e65efe1a3aad4e279b.tar.gz"],
 )
 
-# davido's fork with https://github.com/bazelbuild/rules_closure/issues/248 included
-# to avoid workspace name mismatch warning
+# davido's fork with https://github.com/bazelbuild/rules_closure/pull/235 included
 http_archive(
     name = "io_bazel_rules_closure",
-    sha256 = "83ea206d5fb9178f5faab7ffb173018c2908df5c73bdb0327e3272442ccce661",
-    strip_prefix = "rules_closure-0.8.0",
-    url = "https://github.com/davido/rules_closure/archive/0.8.0.tar.gz",
+    sha256 = "314e4eb701696e267cb911609e2e333e321fe641981a33144f460068ff4e1af3",
+    strip_prefix = "rules_closure-0.11.0",
+    url = "https://github.com/davido/rules_closure/archive/0.11.0.tar.gz",
 )
 
 # File is specific to Polymer and copied from the Closure Github -- should be
@@ -600,8 +599,8 @@
 
 maven_jar(
     name = "dropwizard_core",
-    artifact = "io.dropwizard.metrics:metrics-core:3.2.5",
-    sha1 = "ea2316646e9787c5b2d14ca97f4ef7ad5c6b94e9",
+    artifact = "io.dropwizard.metrics:metrics-core:4.0.2",
+    sha1 = "ec9878842d510cabd6bd6a9da1bebae1ae0cd199",
 )
 
 # When updading Bouncy Castle, also update it in bazlets.
@@ -1100,6 +1099,13 @@
 )
 
 bower_archive(
+    name = "paper-tabs",
+    package = "polymerelements/paper-tabs",
+    sha1 = "b6dd2fbd7ee887534334057a29eb545b940fc5cf",
+    version = "2.0.0",
+)
+
+bower_archive(
     name = "iron-icon",
     package = "polymerelements/iron-icon",
     sha1 = "7da49a0d33cd56017740e0dbcf41d2b71532023f",
@@ -1151,8 +1157,8 @@
 bower_archive(
     name = "polymer",
     package = "polymer/polymer",
-    sha1 = "62ce80a5079c1b97f6c5c6ebf6b350e741b18b9c",
-    version = "1.11.0",
+    sha1 = "158443ab05ade5e2cdc24ebc01f1deef9aebac1b",
+    version = "1.11.3",
 )
 
 bower_archive(
diff --git a/antlr3/BUILD b/antlr3/BUILD
index 0766194..6d7102a 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -7,11 +7,11 @@
     cmd = " && ".join([
         "$(location //lib/antlr:antlr-tool) -o $$TMP $<",
         "cd $$TMP",
-        "zip $$ROOT/$@ $$(find . -type f )",
+        "find . -exec touch -t 198001010000 '{}' ';'",
+        "zip -q $$ROOT/$@ $$(find . -type f)",
     ]),
     tools = [
         "//lib/antlr:antlr-tool",
-        "@bazel_tools//tools/zip:zipper",
     ],
     visibility = ["//visibility:public"],
 )
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index 8d1c326..3501b8b 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -148,7 +148,7 @@
         if options.testmode:
             query_terms = ["status:new", "owner:self", "topic:test-abandon"]
         else:
-            query_terms = ["status:new", "age:%s" % options.age]
+            query_terms = ["status:new", "-is:wip", "age:%s" % options.age]
         if options.branches:
             query_terms += ["branch:%s" % b for b in options.branches]
         elif options.exclude_branches:
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
index dcd1cf1..d3274e6 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ServerInfo.java
@@ -60,8 +60,6 @@
   protected ServerInfo() {}
 
   public static class ChangeConfigInfo extends JavaScriptObject {
-    public final native boolean allowDrafts() /*-{ return this.allow_drafts || false; }-*/;
-
     public final native boolean allowBlame() /*-{ return this.allow_blame || false; }-*/;
 
     public final native int largeChange() /*-{ return this.large_change || 0; }-*/;
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 5755322..77dc8ea 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -106,6 +106,7 @@
 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.index.group.GroupIndexer;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -268,6 +269,7 @@
   @Inject private Provider<AnonymousUser> anonymousUser;
   @Inject private SchemaFactory<ReviewDb> reviewDbProvider;
   @Inject private Groups groups;
+  @Inject private GroupIndexer groupIndexer;
 
   private ProjectResetter resetter;
   private List<Repository> toClose;
@@ -343,6 +345,13 @@
     initSsh();
   }
 
+  private void reindexAllGroups() throws OrmException, IOException, ConfigInvalidException {
+    Iterable<GroupReference> allGroups = groups.getAllGroupReferences(db)::iterator;
+    for (GroupReference group : allGroups) {
+      groupIndexer.index(group.getUUID());
+    }
+  }
+
   protected static Config submitWholeTopicEnabledConfig() {
     Config cfg = new Config();
     cfg.setBoolean("change", null, "submitWholeTopic", true);
@@ -394,10 +403,7 @@
     // 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());
-    }
+    reindexAllGroups();
 
     admin = accountCreator.admin();
     user = accountCreator.user();
@@ -1506,7 +1512,7 @@
     assertThat(m.rcpt()).containsExactly(expected);
     assertThat(((EmailHeader.AddressList) m.headers().get("To")).getAddressList())
         .containsExactly(expected);
-    assertThat(m.headers().get("CC").isEmpty()).isTrue();
+    assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
 
   protected void assertNotifyCc(TestAccount expected) {
@@ -1518,7 +1524,7 @@
     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())
+    assertThat(((EmailHeader.AddressList) m.headers().get("Cc")).getAddressList())
         .containsExactly(expected);
   }
 
@@ -1527,7 +1533,7 @@
     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();
+    assertThat(m.headers().get("Cc").isEmpty()).isTrue();
   }
 
   protected interface ProjectWatchInfoConfiguration {
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 8333005..086cacb 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -113,7 +113,7 @@
       }
       recipients = new HashMap<>();
       recipients.put(TO, parseAddresses(message, "To"));
-      recipients.put(CC, parseAddresses(message, "CC"));
+      recipients.put(CC, parseAddresses(message, "Cc"));
       recipients.put(
           BCC,
           message
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 5262b74..37ef20a 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -317,7 +317,7 @@
     daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
     daemon.setAdditionalSysModuleForTesting(testSysModule);
     daemon.setEnableSshd(desc.useSsh());
-    daemon.setSlave(baseConfig.getBoolean("container", "slave", false));
+    daemon.setSlave(isSlave(baseConfig));
 
     if (desc.memory()) {
       checkArgument(additionalArgs.length == 0, "cannot pass args to in-memory server");
@@ -345,7 +345,7 @@
     cfg.setBoolean("index", "lucene", "testInmemory", true);
     cfg.setString("gitweb", null, "cgi", "");
     daemon.setEnableHttpd(desc.httpd());
-    daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0));
+    daemon.setLuceneModule(LuceneIndexModule.singleVersionAllLatest(0, isSlave(baseConfig)));
     daemon.setDatabaseForTesting(
         ImmutableList.<Module>of(
             new InMemoryTestingDatabaseModule(
@@ -354,6 +354,10 @@
     return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
   }
 
+  private static boolean isSlave(Config baseConfig) {
+    return baseConfig.getBoolean("container", "slave", false);
+  }
+
   private static GerritServer startOnDisk(
       Description desc,
       Path site,
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 629c6bd..24d4b6b 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -30,13 +30,14 @@
 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.DefaultAdvertiseRefsHook;
 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.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectCache;
@@ -208,7 +209,6 @@
 
   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;
@@ -220,7 +220,6 @@
     @Inject
     Upload(
         Provider<CurrentUser> userProvider,
-        VisibleRefFilter.Factory refFilterFactory,
         TransferConfig transferConfig,
         DynamicSet<UploadPackInitializer> uploadPackInitializers,
         DynamicSet<PreUploadHook> preUploadHooks,
@@ -229,7 +228,6 @@
         ProjectCache projectCache,
         PermissionBackend permissionBackend) {
       this.userProvider = userProvider;
-      this.refFilterFactory = refFilterFactory;
       this.transferConfig = transferConfig;
       this.uploadPackInitializers = uploadPackInitializers;
       this.preUploadHooks = preUploadHooks;
@@ -248,11 +246,9 @@
       threadContext.setContext(req);
       current.set(req);
 
+      PermissionBackend.ForProject perm = permissionBackend.user(userProvider).project(req.project);
       try {
-        permissionBackend
-            .user(userProvider)
-            .project(req.project)
-            .check(ProjectPermission.RUN_UPLOAD_PACK);
+        perm.check(ProjectPermission.RUN_UPLOAD_PACK);
       } catch (AuthException e) {
         throw new ServiceNotAuthorizedException();
       } catch (PermissionBackendException e) {
@@ -271,7 +267,7 @@
       UploadPack up = new UploadPack(repo);
       up.setPackConfig(transferConfig.getPackConfig());
       up.setTimeout(transferConfig.getTimeout());
-      up.setAdvertiseRefsHook(refFilterFactory.create(projectState, repo));
+      up.setAdvertiseRefsHook(new DefaultAdvertiseRefsHook(perm, RefFilterOptions.defaults()));
       List<PreUploadHook> hooks = Lists.newArrayList(preUploadHooks);
       hooks.add(uploadValidatorsFactory.create(projectState.getProject(), repo, "localhost-test"));
       up.setPreUploadHook(PreUploadHookChain.newChain(hooks));
diff --git a/java/com/google/gerrit/common/Common.gwt.xml b/java/com/google/gerrit/common/Common.gwt.xml
index fede665..56bbb84 100644
--- a/java/com/google/gerrit/common/Common.gwt.xml
+++ b/java/com/google/gerrit/common/Common.gwt.xml
@@ -18,6 +18,7 @@
   <inherits name='com.google.gwtjsonrpc.GWTJSONRPC'/>
   <inherits name="com.google.gwt.logging.Logging"/>
   <source path="">
+    <exclude name='**/testing/**/*.java'/>
     <include name='**/*.java'/>
   </source>
 </module>
diff --git a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
index 538565a..e8fa896 100644
--- a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
+++ b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.common;
 
 import static com.google.gerrit.common.FileUtil.lastModified;
+import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.GwtIncompatible;
 import com.google.common.collect.ComparisonChain;
@@ -35,12 +36,18 @@
 
   public static void loadSiteLib(Path libdir) {
     try {
-      IoUtil.loadJARs(listJars(libdir));
+      List<Path> jars = listJars(libdir);
+      IoUtil.loadJARs(jars);
+      log.debug("Loaded site libraries: {}", jarList(jars));
     } catch (IOException e) {
       log.error("Error scanning lib directory " + libdir, e);
     }
   }
 
+  private static String jarList(List<Path> jars) {
+    return jars.stream().map(p -> p.getFileName().toString()).collect(joining(","));
+  }
+
   public static List<Path> listJars(Path dir) throws IOException {
     DirectoryStream.Filter<Path> filter =
         new DirectoryStream.Filter<Path>() {
diff --git a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
index b689254..1988d66 100644
--- a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
+++ b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertAbout;
 
-import com.google.common.annotations.GwtIncompatible;
 import com.google.common.truth.ComparableSubject;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
@@ -25,7 +24,6 @@
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 
-@GwtIncompatible("Unemulated com.google.gerrit.common.data.testing.GroupReferenceSubject")
 public class GroupReferenceSubject extends Subject<GroupReferenceSubject, GroupReference> {
 
   public static GroupReferenceSubject assertThat(GroupReference group) {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index b21d3df..0a9f0ec 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -290,6 +290,7 @@
     if (source.get(ChangeField.REVIEWER.getName()) != null) {
       cd.setReviewers(
           ChangeField.parseReviewerFieldValues(
+              cd.getId(),
               FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
                   .transform(JsonElement::getAsString)));
     } else if (fields.contains(ChangeField.REVIEWER.getName())) {
@@ -300,6 +301,7 @@
     if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
       cd.setReviewersByEmail(
           ChangeField.parseReviewerByEmailFieldValues(
+              cd.getId(),
               FluentIterable.from(
                       source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
                   .transform(JsonElement::getAsString)));
@@ -311,6 +313,7 @@
     if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
       cd.setPendingReviewers(
           ChangeField.parseReviewerFieldValues(
+              cd.getId(),
               FluentIterable.from(
                       source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
                   .transform(JsonElement::getAsString)));
@@ -322,6 +325,7 @@
     if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
       cd.setPendingReviewersByEmail(
           ChangeField.parseReviewerByEmailFieldValues(
+              cd.getId(),
               FluentIterable.from(
                       source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
                   .transform(JsonElement::getAsString)));
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index 2d04e11..441653a 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexModule;
@@ -36,52 +37,60 @@
 
 public class ElasticIndexModule extends AbstractModule {
   public static ElasticIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads) {
-    return new ElasticIndexModule(versions, threads, false);
+      Map<String, Integer> versions, int threads, boolean slave) {
+    return new ElasticIndexModule(versions, threads, false, slave);
   }
 
-  public static ElasticIndexModule latestVersionWithOnlineUpgrade() {
-    return new ElasticIndexModule(null, 0, true);
+  public static ElasticIndexModule latestVersionWithOnlineUpgrade(boolean slave) {
+    return new ElasticIndexModule(null, 0, true, slave);
   }
 
-  public static ElasticIndexModule latestVersionWithoutOnlineUpgrade() {
-    return new ElasticIndexModule(null, 0, false);
+  public static ElasticIndexModule latestVersionWithoutOnlineUpgrade(boolean slave) {
+    return new ElasticIndexModule(null, 0, false, slave);
   }
 
   private final Map<String, Integer> singleVersions;
   private final int threads;
   private final boolean onlineUpgrade;
+  private final boolean slave;
 
   private ElasticIndexModule(
-      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade) {
+      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade, boolean slave) {
     if (singleVersions != null) {
       checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
     }
     this.singleVersions = singleVersions;
     this.threads = threads;
     this.onlineUpgrade = onlineUpgrade;
+    this.slave = slave;
   }
 
   @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));
+    if (slave) {
+      bind(AccountIndex.Factory.class).toInstance(ElasticIndexModule::createDummyIndexFactory);
+      bind(ChangeIndex.Factory.class).toInstance(ElasticIndexModule::createDummyIndexFactory);
+      bind(ProjectIndex.Factory.class).toInstance(ElasticIndexModule::createDummyIndexFactory);
+    } else {
+      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(ProjectIndex.class, ElasticProjectIndex.class)
+              .build(ProjectIndex.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));
+    install(new IndexModule(threads, slave));
     if (singleVersions == null) {
       install(new MultiVersionModule());
     } else {
@@ -89,6 +98,11 @@
     }
   }
 
+  @SuppressWarnings("unused")
+  private static <T> T createDummyIndexFactory(Schema<?> schema) {
+    throw new UnsupportedOperationException();
+  }
+
   @Provides
   @Singleton
   IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index 9e02ae5..1e822e3 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -17,7 +17,6 @@
 public class ChangeConfigInfo {
   public Boolean allowBlame;
   public Boolean showAssigneeInChangesTable;
-  public Boolean allowDrafts;
   public Boolean disablePrivateChanges;
   public int largeChange;
   public String replyLabel;
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 4d472da..5bdd9ca 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -25,13 +25,14 @@
 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.DefaultAdvertiseRefsHook;
 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.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectCache;
@@ -238,18 +239,15 @@
   }
 
   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;
@@ -262,12 +260,10 @@
       Repository repo = ServletUtils.getRepository(request);
       ProjectState state = (ProjectState) request.getAttribute(ATT_STATE);
       UploadPack up = (UploadPack) request.getAttribute(ServletUtils.ATTRIBUTE_HANDLER);
-
+      PermissionBackend.ForProject perm =
+          permissionBackend.user(userProvider).project(state.getNameKey());
       try {
-        permissionBackend
-            .user(userProvider)
-            .project(state.getNameKey())
-            .check(ProjectPermission.RUN_UPLOAD_PACK);
+        perm.check(ProjectPermission.RUN_UPLOAD_PACK);
       } catch (AuthException e) {
         GitSmartHttpTools.sendError(
             (HttpServletRequest) request,
@@ -284,8 +280,7 @@
           uploadValidatorsFactory.create(state.getProject(), repo, request.getRemoteHost());
       up.setPreUploadHook(
           PreUploadHookChain.newChain(Lists.newArrayList(up.getPreUploadHook(), uploadValidators)));
-      up.setAdvertiseRefsHook(refFilterFactory.create(state, repo));
-
+      up.setAdvertiseRefsHook(new DefaultAdvertiseRefsHook(perm, RefFilterOptions.defaults()));
       next.doFilter(request, response);
     }
 
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index 86ed398..fd42e82 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -487,10 +487,15 @@
 
     final StringBuilder rdr = new StringBuilder();
     rdr.append(urlProvider.get(req));
+    String nextToken = Url.decode(token);
     if (isNew && !token.startsWith(PageLinks.REGISTER + "/")) {
       rdr.append('#' + PageLinks.REGISTER);
+      if (nextToken.startsWith("#")) {
+        // Need to strip the leading # off the token to fix registration page redirect
+        nextToken = nextToken.substring(1);
+      }
     }
-    rdr.append(Url.decode(token));
+    rdr.append(nextToken);
     rsp.sendRedirect(rdr.toString());
   }
 
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 097f7e7..a498d12 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -32,6 +32,8 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
@@ -71,6 +73,7 @@
 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;
@@ -458,7 +461,7 @@
 
   private static Map<String, String> getParameters(HttpServletRequest req) {
     final Map<String, String> params = new HashMap<>();
-    for (String pair : req.getQueryString().split("[&;]")) {
+    for (String pair : Splitter.on(CharMatcher.anyOf("&;")).split(req.getQueryString())) {
       final int eq = pair.indexOf('=');
       if (0 < eq) {
         String name = pair.substring(0, eq);
@@ -689,8 +692,8 @@
         res.sendRedirect(value);
 
       } else if ("Status".equalsIgnoreCase(key)) {
-        final String[] token = value.split(" ");
-        final int status = Integer.parseInt(token[0]);
+        final List<String> token = Splitter.on(' ').splitToList(value);
+        final int status = Integer.parseInt(token.get(0));
         res.setStatus(status);
 
       } else {
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index fa114e6..d7dca11 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -392,9 +392,9 @@
   private Module createIndexModule() {
     switch (indexType) {
       case LUCENE:
-        return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+        return LuceneIndexModule.latestVersionWithOnlineUpgrade(false);
       case ELASTICSEARCH:
-        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
+        return ElasticIndexModule.latestVersionWithOnlineUpgrade(false);
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index e1c094c..2cdca13 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -457,7 +457,7 @@
       GuiceFilterRequestWrapper reqWrapper = new GuiceFilterRequestWrapper(req);
       String path = pathInfo(req);
 
-      // Special case assets during development that are built by Buck and not
+      // Special case assets during development that are built by Bazel and not
       // served out of the source tree.
       //
       // In the war case, these are either inlined by vulcanize, or live under
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 5b24284..d070966 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -189,6 +189,8 @@
   public static final String XD_METHOD = "$m";
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
+  private static final String PLAIN_TEXT = "text/plain";
+  private static final Pattern TYPE_SPLIT_PATTERN = Pattern.compile("[ ,;][ ,;]*");
 
   /**
    * Garbage prefix inserted before JSON output to prevent XSSI.
@@ -302,7 +304,7 @@
 
         if (isRead(req)) {
           viewData = new ViewData(null, rc.list());
-        } else if (rc instanceof AcceptsPost && "POST".equals(req.getMethod())) {
+        } else if (rc instanceof AcceptsPost && isPost(req)) {
           @SuppressWarnings("unchecked")
           AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) rc;
           viewData = new ViewData(null, ac.post(rsrc));
@@ -317,9 +319,7 @@
             checkPreconditions(req);
           }
         } catch (ResourceNotFoundException e) {
-          if (rc instanceof AcceptsCreate
-              && path.isEmpty()
-              && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
+          if (rc instanceof AcceptsCreate && path.isEmpty() && (isPost(req) || isPut(req))) {
             @SuppressWarnings("unchecked")
             AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) rc;
             viewData = new ViewData(null, ac.create(rsrc, id));
@@ -342,11 +342,11 @@
         if (path.isEmpty()) {
           if (isRead(req)) {
             viewData = new ViewData(null, c.list());
-          } else if (c instanceof AcceptsPost && "POST".equals(req.getMethod())) {
+          } else if (c instanceof AcceptsPost && isPost(req)) {
             @SuppressWarnings("unchecked")
             AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) c;
             viewData = new ViewData(null, ac.post(rsrc));
-          } else if (c instanceof AcceptsDelete && "DELETE".equals(req.getMethod())) {
+          } else if (c instanceof AcceptsDelete && isDelete(req)) {
             @SuppressWarnings("unchecked")
             AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
             viewData = new ViewData(null, ac.delete(rsrc, null));
@@ -361,16 +361,12 @@
           checkPreconditions(req);
           viewData = new ViewData(null, null);
         } catch (ResourceNotFoundException e) {
-          if (c instanceof AcceptsCreate
-              && path.isEmpty()
-              && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) {
+          if (c instanceof AcceptsCreate && path.isEmpty() && (isPost(req) || isPut(req))) {
             @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())) {
+          } else if (c instanceof AcceptsDelete && path.isEmpty() && isDelete(req)) {
             @SuppressWarnings("unchecked")
             AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c;
             viewData = new ViewData(viewData.pluginName, ac.delete(rsrc, id));
@@ -436,10 +432,7 @@
           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) {
+    } catch (MalformedJsonException | JsonParseException e) {
       responseBytes =
           replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e);
     } catch (BadRequestException e) {
@@ -519,16 +512,17 @@
 
   private static HttpServletRequest applyXdOverrides(HttpServletRequest req, QueryParams qp)
       throws BadRequestException {
-    if (!"POST".equals(req.getMethod())) {
+    if (!isPost(req)) {
       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())) {
+      if (!isType(PLAIN_TEXT, req.getContentType())) {
         throw new BadRequestException("invalid " + CONTENT_TYPE);
-      } else if (Strings.isNullOrEmpty(contentType)) {
+      }
+      if (Strings.isNullOrEmpty(contentType)) {
         throw new BadRequestException(XD_CONTENT_TYPE + " required");
       }
     }
@@ -603,7 +597,7 @@
 
     res.setStatus(SC_OK);
     setCorsHeaders(res, origin);
-    res.setContentType("text/plain");
+    res.setContentType(PLAIN_TEXT);
     res.setContentLength(0);
   }
 
@@ -751,13 +745,17 @@
           br.skip(Long.MAX_VALUE);
         }
       }
-    } else if (rawInputRequest(req, type)) {
+    }
+    if (rawInputRequest(req, type)) {
       return parseRawInput(req, type);
-    } else if ("DELETE".equals(req.getMethod()) && hasNoBody(req)) {
+    }
+    if (isDelete(req) && hasNoBody(req)) {
       return null;
-    } else if (hasNoBody(req)) {
+    }
+    if (hasNoBody(req)) {
       return createInstance(type);
-    } else if (isType("text/plain", req.getContentType())) {
+    }
+    if (isType(PLAIN_TEXT, req.getContentType())) {
       try (BufferedReader br = req.getReader()) {
         char[] tmp = new char[256];
         StringBuilder sb = new StringBuilder();
@@ -767,11 +765,11 @@
         }
         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);
     }
+    if (isPost(req) && isType(FORM_TYPE, req.getContentType())) {
+      return OutputFormat.JSON.newGson().fromJson(ParameterParser.formToJson(req), type);
+    }
+    throw new BadRequestException("Expected Content-Type: " + JSON_TYPE);
   }
 
   private void consumeRawInputRequestBody(HttpServletRequest req, Type type) throws IOException {
@@ -922,9 +920,7 @@
                       FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES.translateName( //
                           field.getDeclaringClass().getDeclaredField(field.getName()));
                   names.put(field.getName(), name);
-                } catch (SecurityException e) {
-                  return true;
-                } catch (NoSuchFieldException e) {
+                } catch (SecurityException | NoSuchFieldException e) {
                   return true;
                 }
               }
@@ -1024,7 +1020,7 @@
     }
     res.setHeader("X-FYI-Content-Encoding", "base64");
     res.setHeader("X-FYI-Content-Type", src.getContentType());
-    return b64.setContentType("text/plain").setCharacterEncoding(ISO_8859_1);
+    return b64.setContentType(PLAIN_TEXT).setCharacterEncoding(ISO_8859_1);
   }
 
   private static BinaryResult stackGzip(HttpServletResponse res, BinaryResult src)
@@ -1033,7 +1029,8 @@
     long len = src.getContentLength();
     if (len < 256) {
       return src; // Do not compress very small payloads.
-    } else if (len <= (10 << 20)) {
+    }
+    if (len <= (10 << 20)) {
       gz = compress(src);
       if (len <= gz.getContentLength()) {
         return src;
@@ -1082,12 +1079,10 @@
         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));
-        }
+      if (view != null && 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);
     }
@@ -1115,14 +1110,14 @@
     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(", "))));
     }
+    if (r.isEmpty()) {
+      throw new ResourceNotFoundException(projection);
+    }
+    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) {
@@ -1162,6 +1157,18 @@
     }
   }
 
+  private boolean isDelete(HttpServletRequest req) {
+    return "DELETE".equals(req.getMethod());
+  }
+
+  private static boolean isPost(HttpServletRequest req) {
+    return "POST".equals(req.getMethod());
+  }
+
+  private boolean isPut(HttpServletRequest req) {
+    return "PUT".equals(req.getMethod());
+  }
+
   private static boolean isRead(HttpServletRequest req) {
     return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod());
   }
@@ -1180,7 +1187,7 @@
     if (!Strings.isNullOrEmpty(req.getQueryString())) {
       uri += "?" + req.getQueryString();
     }
-    log.error(String.format("Error in %s %s", req.getMethod(), uri), err);
+    log.error("Error in {} {}", req.getMethod(), uri, err);
 
     if (!res.isCommitted()) {
       res.reset();
@@ -1224,7 +1231,7 @@
     if (!text.endsWith("\n")) {
       text += "\n";
     }
-    return replyBinaryResult(req, res, BinaryResult.create(text).setContentType("text/plain"));
+    return replyBinaryResult(req, res, BinaryResult.create(text).setContentType(PLAIN_TEXT));
   }
 
   private static boolean isMaybeHTML(String text) {
@@ -1246,12 +1253,14 @@
   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 + ",")) {
+    }
+    if (expect.equals(given)) {
       return true;
     }
-    for (String p : given.split("[ ,;][ ,;]*")) {
+    if (given.startsWith(expect + ",")) {
+      return true;
+    }
+    for (String p : Splitter.on(TYPE_SPLIT_PATTERN).split(given)) {
       if (expect.equals(p)) {
         return true;
       }
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 618d754..e8892be 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -704,9 +704,7 @@
 
   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/");
+    return path.endsWith(".jar") && !path.endsWith("-src.jar");
   }
 
   private GerritLauncher() {}
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index b30e66c..468aa67 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -586,12 +586,14 @@
   private void decodeReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     cd.setReviewers(
         ChangeField.parseReviewerFieldValues(
+            cd.getId(),
             FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
   }
 
   private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     cd.setReviewersByEmail(
         ChangeField.parseReviewerByEmailFieldValues(
+            cd.getId(),
             FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
                 .transform(IndexableField::stringValue)));
   }
@@ -599,6 +601,7 @@
   private void decodePendingReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
     cd.setPendingReviewers(
         ChangeField.parseReviewerFieldValues(
+            cd.getId(),
             FluentIterable.from(doc.get(PENDING_REVIEWER_FIELD))
                 .transform(IndexableField::stringValue)));
   }
@@ -607,6 +610,7 @@
       ListMultimap<String, IndexableField> doc, ChangeData cd) {
     cd.setPendingReviewersByEmail(
         ChangeField.parseReviewerByEmailFieldValues(
+            cd.getId(),
             FluentIterable.from(doc.get(PENDING_REVIEWER_BY_EMAIL_FIELD))
                 .transform(IndexableField::stringValue)));
   }
diff --git a/java/com/google/gerrit/lucene/LuceneIndexModule.java b/java/com/google/gerrit/lucene/LuceneIndexModule.java
index d5d6360..e44c9c6 100644
--- a/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexModule;
@@ -37,21 +38,21 @@
 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 singleVersionAllLatest(int threads, boolean slave) {
+    return new LuceneIndexModule(ImmutableMap.of(), threads, false, slave);
   }
 
   public static LuceneIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads) {
-    return new LuceneIndexModule(versions, threads, false);
+      Map<String, Integer> versions, int threads, boolean slave) {
+    return new LuceneIndexModule(versions, threads, false, slave);
   }
 
-  public static LuceneIndexModule latestVersionWithOnlineUpgrade() {
-    return new LuceneIndexModule(null, 0, true);
+  public static LuceneIndexModule latestVersionWithOnlineUpgrade(boolean slave) {
+    return new LuceneIndexModule(null, 0, true, slave);
   }
 
-  public static LuceneIndexModule latestVersionWithoutOnlineUpgrade() {
-    return new LuceneIndexModule(null, 0, false);
+  public static LuceneIndexModule latestVersionWithoutOnlineUpgrade(boolean slave) {
+    return new LuceneIndexModule(null, 0, false, slave);
   }
 
   static boolean isInMemoryTest(Config cfg) {
@@ -61,36 +62,45 @@
   private final Map<String, Integer> singleVersions;
   private final int threads;
   private final boolean onlineUpgrade;
+  private final boolean slave;
 
   private LuceneIndexModule(
-      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade) {
+      Map<String, Integer> singleVersions, int threads, boolean onlineUpgrade, boolean slave) {
     if (singleVersions != null) {
       checkArgument(!onlineUpgrade, "online upgrade is incompatible with single version map");
     }
     this.singleVersions = singleVersions;
     this.threads = threads;
     this.onlineUpgrade = onlineUpgrade;
+    this.slave = slave;
   }
 
   @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));
+    if (slave) {
+      bind(AccountIndex.Factory.class).toInstance(LuceneIndexModule::createDummyIndexFactory);
+      bind(ChangeIndex.Factory.class).toInstance(LuceneIndexModule::createDummyIndexFactory);
+      bind(ProjectIndex.Factory.class).toInstance(LuceneIndexModule::createDummyIndexFactory);
+    } else {
+      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(ProjectIndex.class, LuceneProjectIndex.class)
+              .build(ProjectIndex.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));
+
+    install(new IndexModule(threads, slave));
     if (singleVersions == null) {
       install(new MultiVersionModule());
     } else {
@@ -98,6 +108,11 @@
     }
   }
 
+  @SuppressWarnings("unused")
+  private static <T> T createDummyIndexFactory(Schema<?> schema) {
+    throw new UnsupportedOperationException();
+  }
+
   @Provides
   @Singleton
   IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
diff --git a/java/com/google/gerrit/lucene/LuceneVersionManager.java b/java/com/google/gerrit/lucene/LuceneVersionManager.java
index c7c802f..ce92432 100644
--- a/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -78,7 +78,7 @@
       Path p = getDir(sitePaths, def.getName(), schema);
       boolean isDir = Files.isDirectory(p);
       if (Files.exists(p) && !isDir) {
-        log.warn("Not a directory: %s", p.toAbsolutePath());
+        log.warn("Not a directory: {}", p.toAbsolutePath());
       }
       int v = schema.getVersion();
       versions.put(v, new Version<>(schema, v, isDir, cfg.getReady(def.getName(), v)));
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 3e7ea1b..d73a4c7 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -71,7 +71,7 @@
 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.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.VersionManager;
@@ -334,9 +334,7 @@
     }
     cfgInjector = createCfgInjector();
     config = cfgInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
-    if (!slave) {
-      initIndexType();
-    }
+    initIndexType();
     sysInjector = createSysInjector();
     sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
     manager.add(dbInjector, cfgInjector, sysInjector);
@@ -472,7 +470,9 @@
           }
         });
     modules.add(new GarbageCollectionModule());
-    if (!slave) {
+    if (slave) {
+      modules.add(new PeriodicGroupIndexer.Module());
+    } else {
       modules.add(new AccountDeactivator.Module());
       modules.add(new ChangeCleanupRunner.Module());
     }
@@ -493,9 +493,6 @@
   }
 
   private Module createIndexModule() {
-    if (slave) {
-      return new DummyIndexModule();
-    }
     if (luceneModule != null) {
       return luceneModule;
     }
@@ -506,12 +503,12 @@
     switch (indexType) {
       case LUCENE:
         return onlineUpgrade
-            ? LuceneIndexModule.latestVersionWithOnlineUpgrade()
-            : LuceneIndexModule.latestVersionWithoutOnlineUpgrade();
+            ? LuceneIndexModule.latestVersionWithOnlineUpgrade(slave)
+            : LuceneIndexModule.latestVersionWithoutOnlineUpgrade(slave);
       case ELASTICSEARCH:
         return onlineUpgrade
-            ? ElasticIndexModule.latestVersionWithOnlineUpgrade()
-            : ElasticIndexModule.latestVersionWithoutOnlineUpgrade();
+            ? ElasticIndexModule.latestVersionWithOnlineUpgrade(slave)
+            : ElasticIndexModule.latestVersionWithoutOnlineUpgrade(slave);
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
diff --git a/java/com/google/gerrit/pgm/MigrateToNoteDb.java b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
index 8cd148f..2340a97 100644
--- a/java/com/google/gerrit/pgm/MigrateToNoteDb.java
+++ b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
@@ -33,6 +33,7 @@
 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.gerrit.server.schema.DataSourceType;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -46,8 +47,10 @@
       "Trial mode: migrate changes and turn on reading from NoteDb, but leave ReviewDb as the"
           + " source of truth";
 
+  private static final int ISSUE_8022_THREAD_LIMIT = 4;
+
   @Option(name = "--threads", usage = "Number of threads to use for rebuilding NoteDb")
-  private int threads = Runtime.getRuntime().availableProcessors();
+  private Integer threads;
 
   @Option(
     name = "--project",
@@ -105,12 +108,13 @@
     try {
       mustHaveValidSite();
       dbInjector = createDbInjector(MULTI_USER);
-      threads = ThreadLimiter.limitThreads(dbInjector, threads);
 
       dbManager = new LifecycleManager();
       dbManager.add(dbInjector);
       dbManager.start();
 
+      threads = limitThreads();
+
       sysInjector = createSysInjector();
       sysInjector.injectMembers(this);
       sysManager = new LifecycleManager();
@@ -158,6 +162,27 @@
     return reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
   }
 
+  private int limitThreads() {
+    if (threads != null) {
+      return threads;
+    }
+    int actualThreads;
+    int procs = Runtime.getRuntime().availableProcessors();
+    DataSourceType dsType = dbInjector.getInstance(DataSourceType.class);
+    if (dsType.getDriver().equals("org.h2.Driver") && procs > ISSUE_8022_THREAD_LIMIT) {
+      System.out.println(
+          "Not using more than "
+              + ISSUE_8022_THREAD_LIMIT
+              + " threads due to http://crbug.com/gerrit/8022");
+      System.out.println("Can be increased by passing --threads, but may cause errors");
+      actualThreads = ISSUE_8022_THREAD_LIMIT;
+    } else {
+      actualThreads = procs;
+    }
+    actualThreads = ThreadLimiter.limitThreads(dbInjector, actualThreads);
+    return actualThreads;
+  }
+
   private Injector createSysInjector() {
     return dbInjector.createChildInjector(
         new FactoryModule() {
diff --git a/java/com/google/gerrit/pgm/Passwd.java b/java/com/google/gerrit/pgm/Passwd.java
index d9c3c5d..e4b362c 100644
--- a/java/com/google/gerrit/pgm/Passwd.java
+++ b/java/com/google/gerrit/pgm/Passwd.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InstallAllPlugins;
@@ -49,13 +50,13 @@
   private String password;
 
   private void init() {
-    String[] varParts = sectionAndKey.split("\\.");
-    if (varParts.length != 2) {
+    List<String> varParts = Splitter.on('.').splitToList(sectionAndKey);
+    if (varParts.size() != 2) {
       throw new IllegalArgumentException(
           "Invalid name '" + sectionAndKey + "': expected section.key format");
     }
-    section = varParts[0];
-    key = varParts[1];
+    section = varParts.get(0);
+    key = varParts.get(1);
   }
 
   @Override
diff --git a/java/com/google/gerrit/pgm/ProtoGen.java b/java/com/google/gerrit/pgm/ProtoGen.java
index f659bb0..61a0bd5 100644
--- a/java/com/google/gerrit/pgm/ProtoGen.java
+++ b/java/com/google/gerrit/pgm/ProtoGen.java
@@ -58,8 +58,7 @@
           header = new String(buf.array(), ptr, len, UTF_8);
         }
 
-        String version = com.google.gerrit.common.Version.getVersion();
-        out.write(header.replace("@@VERSION@@", version));
+        out.write(header);
         jsm.generateProto(out);
         out.flush();
       }
@@ -69,7 +68,6 @@
     } finally {
       lock.unlock();
     }
-    System.out.println("Created " + file.getPath());
     return 0;
   }
 }
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index d8451d5..15a330e 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -84,7 +84,6 @@
     dbInjector = createDbInjector(MULTI_USER);
     globalConfig = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     threads = ThreadLimiter.limitThreads(dbInjector, threads);
-    checkNotSlaveMode();
     overrideConfig();
     LifecycleManager dbManager = new LifecycleManager();
     dbManager.add(dbInjector);
@@ -141,25 +140,21 @@
         "invalid index name(s): " + new TreeSet<>(invalid) + " available indices are: " + valid);
   }
 
-  private void checkNotSlaveMode() throws Die {
-    if (globalConfig.getBoolean("container", "slave", false)) {
-      throw die("Cannot run reindex in slave mode");
-    }
-  }
-
   private Injector createSysInjector() {
     Map<String, Integer> versions = new HashMap<>();
     if (changesVersion != null) {
       versions.put(ChangeSchemaDefinitions.INSTANCE.getName(), changesVersion);
     }
+    boolean slave = globalConfig.getBoolean("container", "slave", false);
     List<Module> modules = new ArrayList<>();
     Module indexModule;
     switch (IndexModule.getIndexType(dbInjector)) {
       case LUCENE:
-        indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(versions, threads);
+        indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(versions, threads, slave);
         break;
       case ELASTICSEARCH:
-        indexModule = ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads);
+        indexModule =
+            ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, slave);
         break;
       default:
         throw new IllegalStateException("unsupported index.type");
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index be61061..bc562cc 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -121,6 +121,8 @@
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
     extractMailExample("HeaderHtml.soy");
+    extractMailExample("InboundEmailRejection.soy");
+    extractMailExample("InboundEmailRejectionHtml.soy");
     extractMailExample("Merged.soy");
     extractMailExample("MergedHtml.soy");
     extractMailExample("NewChange.soy");
diff --git a/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java b/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
index f994432..95ff8d7 100644
--- a/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
+++ b/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.pgm.init.api.InitUtil.savePublic;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -168,7 +169,7 @@
 
     if (url.contains("?")) {
       final int q = url.indexOf('?');
-      for (String pair : url.substring(q + 1).split("&")) {
+      for (String pair : Splitter.on('&').split(url.substring(q + 1))) {
         final int eq = pair.indexOf('=');
         if (0 < eq) {
           return false;
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 760afdc..7af6c21 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -54,7 +54,6 @@
 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;
@@ -134,7 +133,6 @@
     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.
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 37e5a34..ace06c5 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -95,6 +95,11 @@
     return Optional.empty();
   }
 
+  /** @return unique name of the user for logging, never {@code null} */
+  public String getLoggableName() {
+    return getUserName().orElseGet(() -> getClass().getSimpleName());
+  }
+
   /** Check if user is the IdentifiedUser */
   public boolean isIdentifiedUser() {
     return false;
@@ -125,11 +130,10 @@
    * Lookup a previously stored property.
    *
    * @param key unique property key.
-   * @return previously stored value, or {@code null}.
+   * @return previously stored value, or {@code Optional#empty()}.
    */
-  @Nullable
-  public <T> T get(PropertyKey<T> key) {
-    return null;
+  public <T> Optional<T> get(PropertyKey<T> key) {
+    return Optional.empty();
   }
 
   /**
@@ -144,7 +148,7 @@
     put(lastLoginExternalIdPropertyKey, externalIdKey);
   }
 
-  public ExternalId.Key getLastLoginExternalIdKey() {
+  public Optional<ExternalId.Key> getLastLoginExternalIdKey() {
     return get(lastLoginExternalIdPropertyKey);
   }
 }
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 72469f2..8379a7c 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -323,6 +323,7 @@
   }
 
   /** @return unique name of the user for logging, never {@code null} */
+  @Override
   public String getLoggableName() {
     return getUserName()
         .orElseGet(
@@ -450,14 +451,13 @@
   }
 
   @Override
-  @Nullable
-  public synchronized <T> T get(PropertyKey<T> key) {
+  public synchronized <T> Optional<T> get(PropertyKey<T> key) {
     if (properties != null) {
       @SuppressWarnings("unchecked")
       T value = (T) properties.get(key);
-      return value;
+      return Optional.ofNullable(value);
     }
-    return null;
+    return Optional.empty();
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 2631ea9..8e1acbc 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -74,9 +74,6 @@
  *
  * <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 (since all config files are optional).
- *
- * <p>By default preferences and project watches are lazily parsed on need. Eager parsing can be
- * requested by {@link #setEagerParsing(boolean)}.
  */
 public class AccountConfig extends VersionedMetaData implements ValidationError.Sink {
   private final Account.Id accountId;
@@ -88,7 +85,6 @@
   private ProjectWatches projectWatches;
   private Preferences preferences;
   private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
-  private boolean eagerParsing;
   private List<ValidationError> validationErrors;
 
   public AccountConfig(Account.Id accountId, Repository allUsersRepo) {
@@ -97,21 +93,6 @@
     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(loadedAccountProperties == null, "Account %s already loaded", accountId.get());
-    this.eagerParsing = eagerParsing;
-    return this;
-  }
-
   @Override
   protected String getRefName() {
     return ref;
@@ -264,10 +245,8 @@
               Preferences.readDefaultConfig(repo),
               this);
 
-      if (eagerParsing) {
-        projectWatches.parse();
-        preferences.parse();
-      }
+      projectWatches.parse();
+      preferences.parse();
     } else {
       loadedAccountProperties = Optional.empty();
 
@@ -365,9 +344,6 @@
   /**
    * 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() {
diff --git a/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
index 0b05b36..73c47ad 100644
--- a/java/com/google/gerrit/server/account/AccountDeactivator.java
+++ b/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -14,19 +14,18 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.config.ScheduleConfig.MISSING_CONFIG;
-
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ScheduleConfig;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.query.account.AccountPredicates;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.concurrent.TimeUnit;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -46,13 +45,13 @@
     private final WorkQueue queue;
     private final AccountDeactivator deactivator;
     private final boolean supportAutomaticAccountActivityUpdate;
-    private final ScheduleConfig scheduleConfig;
+    private final Optional<Schedule> schedule;
 
     @Inject
     Lifecycle(WorkQueue queue, AccountDeactivator deactivator, @GerritServerConfig Config cfg) {
       this.queue = queue;
       this.deactivator = deactivator;
-      scheduleConfig = new ScheduleConfig(cfg, "accountDeactivation");
+      schedule = ScheduleConfig.createSchedule(cfg, "accountDeactivation");
       supportAutomaticAccountActivityUpdate =
           cfg.getBoolean("auth", "autoUpdateAccountActiveStatus", false);
     }
@@ -62,19 +61,7 @@
       if (!supportAutomaticAccountActivityUpdate) {
         return;
       }
-      long interval = scheduleConfig.getInterval();
-      long delay = scheduleConfig.getInitialDelay();
-      if (delay == MISSING_CONFIG && interval == MISSING_CONFIG) {
-        log.info("Ignoring missing accountDeactivator schedule configuration");
-      } else if (delay < 0 || interval <= 0) {
-        log.warn(
-            String.format(
-                "Ignoring invalid accountDeactivator schedule configuration: %s", scheduleConfig));
-      } else {
-        queue
-            .getDefaultQueue()
-            .scheduleAtFixedRate(deactivator, delay, interval, TimeUnit.MILLISECONDS);
-      }
+      schedule.ifPresent(s -> queue.scheduleAtFixedRate(deactivator, s));
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index ee049c0..017bcaa 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -116,7 +116,7 @@
       }
     }
 
-    if (nameOrEmail.matches(ExternalId.USER_NAME_PATTERN_REGEX)) {
+    if (ExternalId.isValidUsername(nameOrEmail)) {
       Optional<AccountState> who = byId.getByUsername(nameOrEmail);
       if (who.isPresent()) {
         return ImmutableSet.of(who.map(a -> a.getAccount().getId()).get());
diff --git a/java/com/google/gerrit/server/account/AccountSshKey.java b/java/com/google/gerrit/server/account/AccountSshKey.java
index 4457fec..aeccc0a 100644
--- a/java/com/google/gerrit/server/account/AccountSshKey.java
+++ b/java/com/google/gerrit/server/account/AccountSshKey.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.reviewdb.client.Account;
 import java.io.Serializable;
+import java.util.List;
 import java.util.Objects;
 
 /** An SSH key approved for use by an {@link Account}. */
@@ -83,9 +85,9 @@
   private String getPublicKeyPart(int index, String defaultValue) {
     String s = getSshPublicKey();
     if (s != null && s.length() > 0) {
-      String[] parts = s.split(" ");
-      if (parts.length > index) {
-        return parts[index];
+      List<String> parts = Splitter.on(' ').splitToList(s);
+      if (parts.size() > index) {
+        return parts.get(index);
       }
     }
     return defaultValue;
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index 34f4eb1..5b9ea69 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -19,7 +19,6 @@
 
 import com.google.common.base.Function;
 import com.google.common.base.Strings;
-import com.google.common.base.Suppliers;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.ImmutableMap;
@@ -40,7 +39,6 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Optional;
-import java.util.function.Supplier;
 import org.apache.commons.codec.DecoderException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.slf4j.Logger;
@@ -108,18 +106,27 @@
             : accountConfig.getExternalIdsRev();
     ImmutableSet<ExternalId> extIds =
         extIdsRev.isPresent()
-            ? externalIds.byAccount(account.getId(), extIdsRev.get())
+            ? ImmutableSet.copyOf(externalIds.byAccount(account.getId(), extIdsRev.get()))
             : ImmutableSet.of();
 
+    // Don't leak references to AccountConfig into the AccountState, since it holds a reference to
+    // an open Repository instance.
+    // TODO(ekempin): Find a way to lazily compute these that doesn't hold the repo open.
+    ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
+        accountConfig.getProjectWatches();
+    GeneralPreferencesInfo generalPreferences = accountConfig.getGeneralPreferences();
+    DiffPreferencesInfo diffPreferences = accountConfig.getDiffPreferences();
+    EditPreferencesInfo editPreferences = accountConfig.getEditPreferences();
+
     return Optional.of(
         new AccountState(
             allUsersName,
             account,
             extIds,
-            Suppliers.memoize(() -> accountConfig.getProjectWatches()),
-            Suppliers.memoize(() -> accountConfig.getGeneralPreferences()),
-            Suppliers.memoize(() -> accountConfig.getDiffPreferences()),
-            Suppliers.memoize(() -> accountConfig.getEditPreferences())));
+            projectWatches,
+            generalPreferences,
+            diffPreferences,
+            editPreferences));
   }
 
   /**
@@ -148,30 +155,30 @@
         allUsersName,
         account,
         ImmutableSet.copyOf(extIds),
-        Suppliers.ofInstance(ImmutableMap.of()),
-        Suppliers.ofInstance(GeneralPreferencesInfo.defaults()),
-        Suppliers.ofInstance(DiffPreferencesInfo.defaults()),
-        Suppliers.ofInstance(EditPreferencesInfo.defaults()));
+        ImmutableMap.of(),
+        GeneralPreferencesInfo.defaults(),
+        DiffPreferencesInfo.defaults(),
+        EditPreferencesInfo.defaults());
   }
 
   private final AllUsersName allUsersName;
   private final Account account;
   private final ImmutableSet<ExternalId> externalIds;
   private final Optional<String> userName;
-  private final Supplier<ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>>> projectWatches;
-  private final Supplier<GeneralPreferencesInfo> generalPreferences;
-  private final Supplier<DiffPreferencesInfo> diffPreferences;
-  private final Supplier<EditPreferencesInfo> editPreferences;
+  private final ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches;
+  private final GeneralPreferencesInfo generalPreferences;
+  private final DiffPreferencesInfo diffPreferences;
+  private final EditPreferencesInfo editPreferences;
   private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
 
   private AccountState(
       AllUsersName allUsersName,
       Account account,
       ImmutableSet<ExternalId> externalIds,
-      Supplier<ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>>> projectWatches,
-      Supplier<GeneralPreferencesInfo> generalPreferences,
-      Supplier<DiffPreferencesInfo> diffPreferences,
-      Supplier<EditPreferencesInfo> editPreferences) {
+      ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches,
+      GeneralPreferencesInfo generalPreferences,
+      DiffPreferencesInfo diffPreferences,
+      EditPreferencesInfo editPreferences) {
     this.allUsersName = allUsersName;
     this.account = account;
     this.externalIds = externalIds;
@@ -239,22 +246,22 @@
 
   /** The project watches of the account. */
   public ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> getProjectWatches() {
-    return projectWatches.get();
+    return projectWatches;
   }
 
   /** The general preferences of the account. */
   public GeneralPreferencesInfo getGeneralPreferences() {
-    return generalPreferences.get();
+    return generalPreferences;
   }
 
   /** The diff preferences of the account. */
   public DiffPreferencesInfo getDiffPreferences() {
-    return diffPreferences.get();
+    return diffPreferences;
   }
 
   /** The edit preferences of the account. */
   public EditPreferencesInfo getEditPreferences() {
-    return editPreferences.get();
+    return editPreferences;
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AuthorizedKeys.java b/java/com/google/gerrit/server/account/AuthorizedKeys.java
index 6f30888..3a6c032 100644
--- a/java/com/google/gerrit/server/account/AuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/AuthorizedKeys.java
@@ -15,11 +15,13 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
 import com.google.gerrit.reviewdb.client.Account;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
+import java.util.regex.Pattern;
 
 public class AuthorizedKeys {
   public static final String FILE_NAME = "authorized_keys";
@@ -28,10 +30,12 @@
 
   @VisibleForTesting public static final String DELETED_KEY_COMMENT = "# DELETED";
 
+  private static final Pattern LINE_SPLIT_PATTERN = Pattern.compile("\\r?\\n");
+
   public static List<Optional<AccountSshKey>> parse(Account.Id accountId, String s) {
     List<Optional<AccountSshKey>> keys = new ArrayList<>();
     int seq = 1;
-    for (String line : s.split("\\r?\\n")) {
+    for (String line : Splitter.on(LINE_SPLIT_PATTERN).split(s)) {
       line = line.trim();
       if (line.isEmpty()) {
         continue;
diff --git a/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
index 0c0778c..5bcb84b 100644
--- a/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -22,7 +22,7 @@
   private AccountGroup.NameKey groupName;
   public String groupDescription;
   public boolean visibleToAll;
-  public AccountGroup.Id ownerGroupId;
+  public AccountGroup.UUID ownerGroupUuid;
   public Collection<? extends Account.Id> initialMembers;
 
   public AccountGroup.NameKey getGroup() {
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index 545998a..8133d9c 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -16,7 +16,6 @@
 
 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. */
@@ -48,11 +47,45 @@
    */
   Optional<InternalGroup> get(AccountGroup.UUID groupUuid);
 
-  /** Notify the cache that a new group was constructed. */
-  void onCreateGroup(AccountGroup.UUID groupUuid) throws IOException;
+  /**
+   * Removes the association of the given ID with a group.
+   *
+   * <p>The next call to {@link #get(AccountGroup.Id)} won't provide a cached value.
+   *
+   * <p>It's safe to call this method if no association exists.
+   *
+   * <p><strong>Note: </strong>This method doesn't touch any associations between names/UUIDs and
+   * groups!
+   *
+   * @param groupId the ID of a possibly associated group
+   */
+  void evict(AccountGroup.Id groupId);
 
-  void evict(AccountGroup.UUID groupUuid, AccountGroup.Id groupId, AccountGroup.NameKey groupName)
-      throws IOException;
+  /**
+   * Removes the association of the given name with a group.
+   *
+   * <p>The next call to {@link #get(AccountGroup.NameKey)} won't provide a cached value.
+   *
+   * <p>It's safe to call this method if no association exists.
+   *
+   * <p><strong>Note: </strong>This method doesn't touch any associations between IDs/UUIDs and
+   * groups!
+   *
+   * @param groupName the name of a possibly associated group
+   */
+  void evict(AccountGroup.NameKey groupName);
 
-  void evictAfterRename(AccountGroup.NameKey oldName) throws IOException;
+  /**
+   * Removes the association of the given UUID with a group.
+   *
+   * <p>The next call to {@link #get(AccountGroup.UUID)} won't provide a cached value.
+   *
+   * <p>It's safe to call this method if no association exists.
+   *
+   * <p><strong>Note: </strong>This method doesn't touch any associations between names/IDs and
+   * groups!
+   *
+   * @param groupUuid the UUID of a possibly associated group
+   */
+  void evict(AccountGroup.UUID groupUuid);
 }
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index 4783f29..58eaadc 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -21,8 +21,6 @@
 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;
@@ -31,10 +29,8 @@
 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;
 
@@ -72,18 +68,15 @@
   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) {
+      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID) {
     this.byId = byId;
     this.byName = byName;
     this.byUUID = byUUID;
-    this.indexer = indexer;
   }
 
   @Override
@@ -97,29 +90,6 @@
   }
 
   @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();
@@ -147,34 +117,37 @@
   }
 
   @Override
-  public void onCreateGroup(AccountGroup.UUID groupUuid) throws IOException {
-    indexer.get().index(groupUuid);
+  public void evict(AccountGroup.Id groupId) {
+    if (groupId != null) {
+      byId.invalidate(groupId);
+    }
+  }
+
+  @Override
+  public void evict(AccountGroup.NameKey groupName) {
+    if (groupName != null) {
+      byName.invalidate(groupName.get());
+    }
+  }
+
+  @Override
+  public void evict(AccountGroup.UUID groupUuid) {
+    if (groupUuid != null) {
+      byUUID.invalidate(groupUuid.get());
+    }
   }
 
   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;
+    ByIdLoader(Provider<InternalGroupQuery> groupQueryProvider) {
       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);
-      }
+      return groupQueryProvider.get().byId(key);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index 119dc5b..4b223bf 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -20,15 +20,12 @@
 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 {
@@ -56,30 +53,16 @@
 
   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) {
+    Factory(PermissionBackend permissionBackend, 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) {
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 66b6b10..d8472a6 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -21,16 +21,12 @@
 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;
@@ -145,81 +141,41 @@
 
   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;
+    GroupsWithMemberLoader(Provider<InternalGroupQuery> groupQueryProvider) {
       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());
-      }
+      return groupQueryProvider
+          .get()
+          .byMember(memberId)
+          .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;
+    ParentGroupsLoader(Provider<InternalGroupQuery> groupQueryProvider) {
       this.groupQueryProvider = groupQueryProvider;
-      this.groupCache = groupCache;
     }
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
-      GroupIndex groupIndex = groupIndexProvider.get();
-      if (groupIndex != null && groupIndex.getSchema().hasField(GroupField.SUBGROUP)) {
-        return groupQueryProvider
-            .get()
-            .bySubgroup(key)
-            .stream()
-            .map(InternalGroup::getGroupUUID)
-            .collect(toImmutableList());
-      }
-      try (ReviewDb db = schema.open()) {
-        return Groups.getParentGroupsFromReviewDb(db, key)
-            .map(groupCache::get)
-            .flatMap(Streams::stream)
-            .map(InternalGroup::getGroupUUID)
-            .collect(toImmutableList());
-      }
+      return groupQueryProvider
+          .get()
+          .bySubgroup(key)
+          .stream()
+          .map(InternalGroup::getGroupUUID)
+          .collect(toImmutableList());
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/HashedPassword.java b/java/com/google/gerrit/server/account/HashedPassword.java
index 0323f4e..427c8c3 100644
--- a/java/com/google/gerrit/server/account/HashedPassword.java
+++ b/java/com/google/gerrit/server/account/HashedPassword.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.base.Preconditions;
+import com.google.common.base.Splitter;
 import com.google.common.io.BaseEncoding;
 import com.google.common.primitives.Ints;
 import java.nio.charset.StandardCharsets;
 import java.security.SecureRandom;
+import java.util.List;
 import org.apache.commons.codec.DecoderException;
 import org.bouncycastle.crypto.generators.BCrypt;
 import org.bouncycastle.util.Arrays;
@@ -46,12 +48,12 @@
       throw new DecoderException("unrecognized algorithm");
     }
 
-    String[] fields = encoded.split(":");
-    if (fields.length != 4) {
+    List<String> fields = Splitter.on(':').splitToList(encoded);
+    if (fields.size() != 4) {
       throw new DecoderException("want 4 fields");
     }
 
-    Integer cost = Ints.tryParse(fields[1]);
+    Integer cost = Ints.tryParse(fields.get(1));
     if (cost == null) {
       throw new DecoderException("cost parse failed");
     }
@@ -60,11 +62,11 @@
       throw new DecoderException("cost should be 4..31 inclusive, got " + cost);
     }
 
-    byte[] salt = codec.decode(fields[2]);
+    byte[] salt = codec.decode(fields.get(2));
     if (salt.length != 16) {
       throw new DecoderException("salt should be 16 bytes, got " + salt.length);
     }
-    return new HashedPassword(codec.decode(fields[3]), salt, cost);
+    return new HashedPassword(codec.decode(fields.get(3)), salt, cost);
   }
 
   private static byte[] hashPassword(String password, byte[] salt, int cost) {
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 3b6ebd1..ffd413a 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -51,7 +51,7 @@
   private static final String USER_NAME_PATTERN_LAST_REGEX = "[a-zA-Z0-9]";
 
   /** Regular expression that a username must match. */
-  public static final String USER_NAME_PATTERN_REGEX =
+  private static final String USER_NAME_PATTERN_REGEX =
       "^"
           + //
           "("
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index ffb4e76..a8844cd 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.SetMultimap;
 import com.google.gerrit.reviewdb.client.Account;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
@@ -26,6 +26,8 @@
  *
  * <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.
+ *
+ * <p>All returned collections are unmodifiable.
  */
 interface ExternalIdCache {
   void onReplace(
@@ -35,17 +37,17 @@
       Collection<ExternalId> toAdd)
       throws IOException;
 
-  ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
+  Set<ExternalId> byAccount(Account.Id accountId) throws IOException;
 
-  ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException;
+  Set<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException;
 
-  ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
+  SetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
 
-  ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
+  SetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
 
-  ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException;
+  SetMultimap<String, ExternalId> allByEmail() throws IOException;
 
-  default ImmutableSet<ExternalId> byEmail(String email) throws IOException {
+  default Set<ExternalId> byEmail(String email) throws IOException {
     return byEmails(email).get(email);
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index 25789a1..1f77773 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -14,23 +14,23 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap;
-
 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.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multimaps;
+import com.google.common.collect.SetMultimap;
 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.Set;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
@@ -95,22 +95,22 @@
   }
 
   @Override
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
+  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
     return get().byAccount().get(accountId);
   }
 
   @Override
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
+  public Set<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
     return get(rev).byAccount().get(accountId);
   }
 
   @Override
-  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+  public SetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
     return get().byAccount();
   }
 
   @Override
-  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+  public SetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
     AllExternalIds allExternalIds = get();
     ImmutableSetMultimap.Builder<String, ExternalId> byEmails = ImmutableSetMultimap.builder();
     for (String email : emails) {
@@ -120,7 +120,7 @@
   }
 
   @Override
-  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
+  public SetMultimap<String, ExternalId> allByEmail() throws IOException {
     return get().byEmail();
   }
 
@@ -179,21 +179,30 @@
     }
   }
 
+  /**
+   * Cache value containing all external IDs.
+   *
+   * <p>All returned fields are unmodifiable.
+   */
   @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));
+      SetMultimap<String, ExternalId> byEmailCopy =
+          MultimapBuilder.hashKeys(byAccount.size()).hashSetValues(1).build();
+      byAccount
+          .values()
+          .stream()
+          .filter(e -> !Strings.isNullOrEmpty(e.email()))
+          .forEach(e -> byEmailCopy.put(e.email(), e));
+
       return new AutoValue_ExternalIdCacheImpl_AllExternalIds(
-          ImmutableSetMultimap.copyOf(byAccount), byEmail);
+          Multimaps.unmodifiableSetMultimap(
+              MultimapBuilder.hashKeys(byAccount.size()).hashSetValues(5).build(byAccount)),
+          byEmailCopy);
     }
 
-    public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
+    public abstract SetMultimap<Account.Id, ExternalId> byAccount();
 
-    public abstract ImmutableSetMultimap<String, ExternalId> byEmail();
+    public abstract SetMultimap<String, ExternalId> byEmail();
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index 11f5855c..167af45 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -17,12 +17,13 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.SetMultimap;
 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;
 
@@ -66,13 +67,12 @@
   }
 
   /** Returns the external IDs of the specified account. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
+  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 ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme)
-      throws IOException {
+  public Set<ExternalId> byAccount(Account.Id accountId, String scheme) throws IOException {
     return byAccount(accountId)
         .stream()
         .filter(e -> e.key().isScheme(scheme))
@@ -80,12 +80,12 @@
   }
 
   /** Returns the external IDs of the specified account. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
+  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 ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme, ObjectId rev)
+  public Set<ExternalId> byAccount(Account.Id accountId, String scheme, ObjectId rev)
       throws IOException {
     return byAccount(accountId, rev)
         .stream()
@@ -94,7 +94,7 @@
   }
 
   /** Returns all external IDs by account. */
-  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+  public SetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
     return externalIdCache.allByAccount();
   }
 
@@ -111,7 +111,7 @@
    *
    * @see #byEmails(String...)
    */
-  public ImmutableSet<ExternalId> byEmail(String email) throws IOException {
+  public Set<ExternalId> byEmail(String email) throws IOException {
     return externalIdCache.byEmail(email);
   }
 
@@ -129,12 +129,12 @@
    *
    * @see #byEmail(String)
    */
-  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+  public SetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
     return externalIdCache.byEmails(emails);
   }
 
   /** Returns all external IDs by email. */
-  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
+  public SetMultimap<String, ExternalId> allByEmail() throws IOException {
     return externalIdCache.allByEmail();
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 84af4e8..03e2162 100644
--- a/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -22,6 +22,7 @@
 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.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.TagResource;
 import com.google.gerrit.server.restapi.project.CreateTag;
@@ -88,7 +89,7 @@
     }
   }
 
-  private TagResource resource() throws RestApiException, IOException {
+  private TagResource resource() throws RestApiException, IOException, PermissionBackendException {
     return tags.parse(project, IdString.fromDecoded(ref));
   }
 }
diff --git a/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 01ebb1f..c7d3f73 100644
--- a/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -91,7 +91,7 @@
   }
 
   private Account.Id createAccountByLdap(String user) throws CmdLineException, IOException {
-    if (!user.matches(ExternalId.USER_NAME_PATTERN_REGEX)) {
+    if (!ExternalId.isValidUsername(user)) {
       throw new CmdLineException(owner, "user \"" + user + "\" not found");
     }
 
diff --git a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
index 0e841ec..adb5f63 100644
--- a/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.args4j;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -23,6 +24,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import java.util.List;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -47,16 +49,16 @@
   @Override
   public final int parseArguments(Parameters params) throws CmdLineException {
     final String token = params.getParameter(0);
-    final String[] tokens = token.split(",");
-    if (tokens.length != 3) {
+    final List<String> tokens = Splitter.on(',').splitToList(token);
+    if (tokens.size() != 3) {
       throw new CmdLineException(
           owner, "change should be specified as <project>,<branch>,<change-id>");
     }
 
     try {
-      final Change.Key key = Change.Key.parse(tokens[2]);
-      final Project.NameKey project = new Project.NameKey(tokens[0]);
-      final Branch.NameKey branch = new Branch.NameKey(project, tokens[1]);
+      final Change.Key key = Change.Key.parse(tokens.get(2));
+      final Project.NameKey project = new Project.NameKey(tokens.get(0));
+      final Branch.NameKey branch = new Branch.NameKey(project, tokens.get(1));
       for (ChangeData cd : queryProvider.get().byBranchKey(branch, key)) {
         setter.addValue(cd.getId());
         return 1;
diff --git a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
index 5a3d82c..ae26e12 100644
--- a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
@@ -23,14 +23,14 @@
   /**
    * Gets the acting user who is updating the group.
    *
-   * @return the {@link Account.Id} of the acting user.
+   * @return the {@link com.google.gerrit.reviewdb.client.Account.Id} of the acting user.
    */
   Account.Id getActor();
 
   /**
-   * Gets the {@link AccountGroup.UUID} of the updated group.
+   * Gets the {@link com.google.gerrit.reviewdb.client.AccountGroup.UUID} of the updated group.
    *
-   * @return the {@link AccountGroup.UUID} of the updated group.
+   * @return the {@link com.google.gerrit.reviewdb.client.AccountGroup.UUID} of the updated group.
    */
   AccountGroup.UUID getUpdatedGroup();
 
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index eb7d099..a887760 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.server.config.ScheduleConfig.MISSING_CONFIG;
-
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
-import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
@@ -28,8 +25,6 @@
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -58,22 +53,7 @@
 
     @Override
     public void start() {
-      ScheduleConfig scheduleConfig = cfg.getScheduleConfig();
-      long interval = scheduleConfig.getInterval();
-      long delay = scheduleConfig.getInitialDelay();
-      if (delay == MISSING_CONFIG && interval == MISSING_CONFIG) {
-        log.info("Ignoring missing changeCleanup schedule configuration");
-      } else if (delay < 0 || interval <= 0) {
-        log.warn(
-            String.format(
-                "Ignoring invalid changeCleanup schedule configuration: %s", scheduleConfig));
-      } else {
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError =
-            queue
-                .getDefaultQueue()
-                .scheduleAtFixedRate(runner, delay, interval, TimeUnit.MILLISECONDS);
-      }
+      cfg.getSchedule().ifPresent(s -> queue.scheduleAtFixedRate(runner, s));
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index c058e33..113a6bf 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -656,11 +656,11 @@
     return result;
   }
 
-  private boolean submittable(ChangeData cd) throws OrmException {
+  private boolean submittable(ChangeData cd) {
     return SubmitRecord.findOkRecord(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)).isPresent();
   }
 
-  private List<SubmitRecord> submitRecords(ChangeData cd) throws OrmException {
+  private List<SubmitRecord> submitRecords(ChangeData cd) {
     return cd.submitRecords(SUBMIT_RULE_OPTIONS_LENIENT);
   }
 
@@ -712,7 +712,7 @@
   }
 
   private Map<String, LabelWithStatus> initLabels(
-      ChangeData cd, LabelTypes labelTypes, boolean standard) throws OrmException {
+      ChangeData cd, LabelTypes labelTypes, boolean standard) {
     Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels == null) {
diff --git a/java/com/google/gerrit/server/change/ReviewerSuggestion.java b/java/com/google/gerrit/server/change/ReviewerSuggestion.java
index a2dd8b5..198a5fd 100644
--- a/java/com/google/gerrit/server/change/ReviewerSuggestion.java
+++ b/java/com/google/gerrit/server/change/ReviewerSuggestion.java
@@ -24,19 +24,20 @@
 /**
  * Listener to provide reviewer suggestions.
  *
- * <p>Invoked by Gerrit a user who is searching for a reviewer to add to a change.
+ * <p>Invoked by Gerrit when a user clicks "Add Reviewer" on a change.
  */
 @ExtensionPoint
 public interface ReviewerSuggestion {
   /**
-   * Reviewer suggestion.
+   * Suggest reviewers to add to a change.
    *
    * @param project The name key of the project the suggestion is for.
-   * @param changeId The changeId that the suggestion is for. Can be an {@code null}.
-   * @param query The query as typed by the user. Can be an {@code null}.
+   * @param changeId The changeId that the suggestion is for. Can be {@code null}.
+   * @param query The query as typed by the user. Can be {@code null}.
    * @param candidates A set of candidates for the ranking. Can be empty.
-   * @return Set of suggested reviewers as a tuple of account id and score. The account ids listed
-   *     here don't have to be a part of candidates.
+   * @return Set of {@link SuggestedReviewer}s. The {@link
+   *     com.google.gerrit.reviewdb.client.Account.Id}s listed here don't have to be included in
+   *     {@code candidates}.
    */
   Set<SuggestedReviewer> suggestReviewers(
       Project.NameKey project,
diff --git a/java/com/google/gerrit/server/config/ChangeCleanupConfig.java b/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
index b2b5fab..632293e 100644
--- a/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
+++ b/java/com/google/gerrit/server/config/ChangeCleanupConfig.java
@@ -16,8 +16,10 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -33,7 +35,7 @@
           + "\n"
           + "If this change is still wanted it should be restored.";
 
-  private final ScheduleConfig scheduleConfig;
+  private final Optional<Schedule> schedule;
   private final long abandonAfter;
   private final boolean abandonIfMergeable;
   private final String abandonMessage;
@@ -41,7 +43,7 @@
   @Inject
   ChangeCleanupConfig(
       @GerritServerConfig Config cfg, @CanonicalWebUrl @Nullable String canonicalWebUrl) {
-    scheduleConfig = new ScheduleConfig(cfg, SECTION);
+    schedule = ScheduleConfig.createSchedule(cfg, SECTION);
     abandonAfter = readAbandonAfter(cfg);
     abandonIfMergeable = cfg.getBoolean(SECTION, null, KEY_ABANDON_IF_MERGEABLE, true);
     abandonMessage = readAbandonMessage(cfg, canonicalWebUrl);
@@ -64,8 +66,8 @@
     return abandonMessage;
   }
 
-  public ScheduleConfig getScheduleConfig() {
-    return scheduleConfig;
+  public Optional<Schedule> getSchedule() {
+    return schedule;
   }
 
   public long getAbandonAfter() {
diff --git a/java/com/google/gerrit/server/config/GcConfig.java b/java/com/google/gerrit/server/config/GcConfig.java
index 36f5e29..3e28827 100644
--- a/java/com/google/gerrit/server/config/GcConfig.java
+++ b/java/com/google/gerrit/server/config/GcConfig.java
@@ -14,24 +14,26 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ConfigConstants;
 
 @Singleton
 public class GcConfig {
-  private final ScheduleConfig scheduleConfig;
+  private final Optional<Schedule> schedule;
   private final boolean aggressive;
 
   @Inject
   GcConfig(@GerritServerConfig Config cfg) {
-    scheduleConfig = new ScheduleConfig(cfg, ConfigConstants.CONFIG_GC_SECTION);
+    schedule = ScheduleConfig.createSchedule(cfg, ConfigConstants.CONFIG_GC_SECTION);
     aggressive = cfg.getBoolean(ConfigConstants.CONFIG_GC_SECTION, "aggressive", false);
   }
 
-  public ScheduleConfig getScheduleConfig() {
-    return scheduleConfig;
+  public Optional<Schedule> getSchedule() {
+    return schedule;
   }
 
   public boolean isAggressive() {
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index e29968b..77312be 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -121,7 +121,6 @@
 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;
@@ -137,6 +136,7 @@
 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.AutoReplyMailFilter;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.ListMailFilter;
 import com.google.gerrit.server.mail.MailFilter;
@@ -146,6 +146,7 @@
 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.InboundEmailRejectionSender;
 import com.google.gerrit.server.mail.send.MailSoyTofuProvider;
 import com.google.gerrit.server.mail.send.MailTemplates;
 import com.google.gerrit.server.mail.send.MergedSender;
@@ -266,7 +267,7 @@
     factory(RegisterNewEmailSender.Factory.class);
     factory(ReplacePatchSetSender.Factory.class);
     factory(SetAssigneeSender.Factory.class);
-    factory(VisibleRefFilter.Factory.class);
+    factory(InboundEmailRejectionSender.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
     factory(ProjectOwnerGroupsProvider.Factory.class);
@@ -393,6 +394,9 @@
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
+    bind(AutoReplyMailFilter.class)
+        .annotatedWith(Exports.named("AutoReplyMailFilter"))
+        .to(AutoReplyMailFilter.class);
 
     factory(UploadValidators.Factory.class);
     DynamicSet.setOf(binder(), UploadValidationListener.class);
diff --git a/java/com/google/gerrit/server/config/ScheduleConfig.java b/java/com/google/gerrit/server/config/ScheduleConfig.java
index f7f8238..9fff101 100644
--- a/java/com/google/gerrit/server/config/ScheduleConfig.java
+++ b/java/com/google/gerrit/server/config/ScheduleConfig.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static java.time.ZoneId.systemDefault;
 
+import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.annotations.VisibleForTesting;
-import java.text.MessageFormat;
+import com.google.gerrit.common.Nullable;
 import java.time.DayOfWeek;
 import java.time.Duration;
 import java.time.LocalTime;
@@ -26,158 +29,181 @@
 import java.time.format.DateTimeParseException;
 import java.time.temporal.ChronoUnit;
 import java.util.Locale;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class ScheduleConfig {
+/**
+ * This class reads a schedule for running a periodic background job from a Git config.
+ *
+ * <p>A schedule configuration consists of two parameters:
+ *
+ * <ul>
+ *   <li>{@code interval}: Interval for running the periodic background job. The interval must be
+ *       larger than zero. The following suffixes are supported to define the time unit for the
+ *       interval:
+ *       <ul>
+ *         <li>{@code s}, {@code sec}, {@code second}, {@code seconds}
+ *         <li>{@code m}, {@code min}, {@code minute}, {@code minutes}
+ *         <li>{@code h}, {@code hr}, {@code hour}, {@code hours}
+ *         <li>{@code d}, {@code day}, {@code days}
+ *         <li>{@code w}, {@code week}, {@code weeks} ({@code 1 week} is treated as {@code 7 days})
+ *         <li>{@code mon}, {@code month}, {@code months} ({@code 1 month} is treated as {@code 30
+ *             days})
+ *         <li>{@code y}, {@code year}, {@code years} ({@code 1 year} is treated as {@code 365
+ *             days})
+ *       </ul>
+ *   <li>{@code startTime}: The start time defines the first execution of the periodic background
+ *       job. If the configured {@code interval} is shorter than {@code startTime - now} the start
+ *       time will be preponed by the maximum integral multiple of {@code interval} so that the
+ *       start time is still in the future. {@code startTime} must have one of the following
+ *       formats:
+ *       <ul>
+ *         <li>{@code <day of week> <hours>:<minutes>}
+ *         <li>{@code <hours>:<minutes>}
+ *       </ul>
+ *       The placeholders can have the following values:
+ *       <ul>
+ *         <li>{@code <day of week>}: {@code Mon}, {@code Tue}, {@code Wed}, {@code Thu}, {@code
+ *             Fri}, {@code Sat}, {@code Sun}
+ *         <li>{@code <hours>}: {@code 00}-{@code 23}
+ *         <li>{@code <minutes>}: {@code 00}-{@code 59}
+ *       </ul>
+ *       The timezone cannot be specified but is always the system default time-zone.
+ * </ul>
+ *
+ * <p>The section and the subsection from which the {@code interval} and {@code startTime}
+ * parameters are read can be configured.
+ *
+ * <p>Examples for a schedule configuration:
+ *
+ * <ul>
+ *   <li>
+ *       <pre>
+ * foo.startTime = Fri 10:30
+ * foo.interval  = 2 day
+ * </pre>
+ *       Assuming that the server is started on {@code Mon 7:00} then {@code startTime - now} is
+ *       {@code 4 days 3:30 hours}. This is larger than the interval hence the start time is
+ *       preponed by the maximum integral multiple of the interval so that start time is still in
+ *       the future, i.e. preponed by 4 days. This yields a start time of {@code Mon 10:30}, next
+ *       executions are {@code Wed 10:30}, {@code Fri 10:30}. etc.
+ *   <li>
+ *       <pre>
+ * foo.startTime = 6:00
+ * foo.interval = 1 day
+ * </pre>
+ *       Assuming that the server is started on {@code Mon 7:00} then this yields the first run on
+ *       next Tuesday at 6:00 and a repetition interval of 1 day.
+ * </ul>
+ */
+@AutoValue
+public abstract 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;
+  @VisibleForTesting static final String KEY_INTERVAL = "interval";
+  @VisibleForTesting static final String KEY_STARTTIME = "startTime";
 
-  public ScheduleConfig(Config rc, String section) {
-    this(rc, section, null);
+  private static final long MISSING_CONFIG = -1L;
+  private static final long INVALID_CONFIG = -2L;
+
+  public static Optional<Schedule> createSchedule(Config config, String section) {
+    return builder(config, section).buildSchedule();
   }
 
-  public ScheduleConfig(Config rc, String section, String subsection) {
-    this(rc, section, subsection, ZonedDateTime.now(systemDefault()));
+  public static ScheduleConfig.Builder builder(Config config, String section) {
+    return new AutoValue_ScheduleConfig.Builder()
+        .setNow(computeNow())
+        .setKeyInterval(KEY_INTERVAL)
+        .setKeyStartTime(KEY_STARTTIME)
+        .setConfig(config)
+        .setSection(section);
   }
 
-  public ScheduleConfig(
-      Config rc, String section, String subsection, String keyInterval, String keyStartTime) {
-    this(rc, section, subsection, keyInterval, keyStartTime, ZonedDateTime.now(systemDefault()));
-  }
+  abstract Config config();
 
-  @VisibleForTesting
-  ScheduleConfig(Config rc, String section, String subsection, ZonedDateTime now) {
-    this(rc, section, subsection, KEY_INTERVAL, KEY_STARTTIME, now);
-  }
+  abstract String section();
 
-  @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);
+  @Nullable
+  abstract String subsection();
+
+  abstract String keyInterval();
+
+  abstract String keyStartTime();
+
+  abstract ZonedDateTime now();
+
+  @Memoized
+  public Optional<Schedule> schedule() {
+    long interval = computeInterval(config(), section(), subsection(), keyInterval());
+
+    long initialDelay;
     if (interval > 0) {
-      this.initialDelay = initialDelay(rc, section, subsection, keyStartTime, now, interval);
+      initialDelay =
+          computeInitialDelay(config(), section(), subsection(), keyStartTime(), now(), interval);
     } else {
-      this.initialDelay = interval;
+      initialDelay = interval;
     }
+
+    if (isInvalidOrMissing(interval, initialDelay)) {
+      return Optional.empty();
+    }
+
+    return Optional.of(Schedule.create(interval, initialDelay));
   }
 
-  /**
-   * 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;
-  }
+  private boolean isInvalidOrMissing(long interval, long initialDelay) {
+    String key = section() + (subsection() != null ? "." + subsection() : "");
+    if (interval == MISSING_CONFIG && initialDelay == MISSING_CONFIG) {
+      log.info("No schedule configuration for \"{}\".", key);
+      return true;
+    }
 
-  /** 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) {
+    if (interval == MISSING_CONFIG) {
       log.error(
-          MessageFormat.format("Invalid {0} schedule parameter \"{0}.{1}\"", section, keyInterval),
-          e);
-      interval = INVALID_CONFIG;
+          "Incomplete schedule configuration for \"{}\" is ignored. Missing value for \"{}\".",
+          key,
+          key + "." + keyInterval());
+      return true;
     }
-    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) {
+    if (initialDelay == MISSING_CONFIG) {
       log.error(
-          MessageFormat.format("Invalid {0} schedule parameter \"{0}.{1}\"", section, keyStartTime),
-          e2);
-      delay = INVALID_CONFIG;
+          "Incomplete schedule configuration for \"{}\" is ignored. Missing value for \"{}\".",
+          key,
+          key + "." + keyStartTime());
+      return true;
     }
-    return delay;
+
+    if (interval <= 0 || initialDelay < 0) {
+      log.error("Invalid schedule configuration for \"{}\" is ignored. ", key);
+      return true;
+    }
+
+    return false;
   }
 
   @Override
   public String toString() {
     StringBuilder b = new StringBuilder();
-    b.append(formatValue(keyInterval));
+    b.append(formatValue(keyInterval()));
     b.append(", ");
-    b.append(formatValue(keyStartTime));
+    b.append(formatValue(keyStartTime()));
     return b.toString();
   }
 
   private String formatValue(String key) {
     StringBuilder b = new StringBuilder();
-    b.append(section);
-    if (subsection != null) {
+    b.append(section());
+    if (subsection() != null) {
       b.append(".");
-      b.append(subsection);
+      b.append(subsection());
     }
     b.append(".");
     b.append(key);
-    String value = rc.getString(section, subsection, key);
+    String value = config().getString(section(), subsection(), key);
     if (value != null) {
       b.append(" = ");
       b.append(value);
@@ -186,4 +212,135 @@
     }
     return b.toString();
   }
+
+  private static long computeInterval(
+      Config rc, String section, String subsection, String keyInterval) {
+    try {
+      return ConfigUtil.getTimeUnit(
+          rc, section, subsection, keyInterval, MISSING_CONFIG, TimeUnit.MILLISECONDS);
+    } catch (IllegalArgumentException e) {
+      return INVALID_CONFIG;
+    }
+  }
+
+  private static long computeInitialDelay(
+      Config rc,
+      String section,
+      String subsection,
+      String keyStartTime,
+      ZonedDateTime now,
+      long interval) {
+    String start = rc.getString(section, subsection, keyStartTime);
+    if (start == null) {
+      return MISSING_CONFIG;
+    }
+    return computeInitialDelay(interval, start, now);
+  }
+
+  private static long computeInitialDelay(long interval, String start) {
+    return computeInitialDelay(interval, start, computeNow());
+  }
+
+  private static long computeInitialDelay(long interval, String start, ZonedDateTime now) {
+    checkNotNull(start);
+
+    try {
+      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);
+      long delay = Duration.between(now, startTime).toMillis() % interval;
+      if (delay <= 0) {
+        delay += interval;
+      }
+      return delay;
+    } catch (DateTimeParseException e) {
+      return INVALID_CONFIG;
+    }
+  }
+
+  private static ZonedDateTime computeNow() {
+    return ZonedDateTime.now(systemDefault());
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setConfig(Config config);
+
+    public abstract Builder setSection(String section);
+
+    public abstract Builder setSubsection(@Nullable String subsection);
+
+    public abstract Builder setKeyInterval(String keyInterval);
+
+    public abstract Builder setKeyStartTime(String keyStartTime);
+
+    @VisibleForTesting
+    abstract Builder setNow(ZonedDateTime now);
+
+    abstract ScheduleConfig build();
+
+    public Optional<Schedule> buildSchedule() {
+      return build().schedule();
+    }
+  }
+
+  @AutoValue
+  public abstract static class Schedule {
+    /** Number of milliseconds between events. */
+    public abstract long 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 abstract long initialDelay();
+
+    /**
+     * Creates a schedule.
+     *
+     * <p>{@link ScheduleConfig} defines details about which values are valid for the {@code
+     * interval} and {@code startTime} parameters.
+     *
+     * @param interval the interval in milliseconds
+     * @param startTime the start time as "{@code <day of week> <hours>:<minutes>}" or "{@code
+     *     <hours>:<minutes>}"
+     * @return the schedule
+     * @throws IllegalArgumentException if any of the parameters is invalid
+     */
+    public static Schedule createOrFail(long interval, String startTime) {
+      return create(interval, startTime).orElseThrow(IllegalArgumentException::new);
+    }
+
+    /**
+     * Creates a schedule.
+     *
+     * <p>{@link ScheduleConfig} defines details about which values are valid for the {@code
+     * interval} and {@code startTime} parameters.
+     *
+     * @param interval the interval in milliseconds
+     * @param startTime the start time as "{@code <day of week> <hours>:<minutes>}" or "{@code
+     *     <hours>:<minutes>}"
+     * @return the schedule or {@link Optional#empty()} if any of the parameters is invalid
+     */
+    public static Optional<Schedule> create(long interval, String startTime) {
+      long initialDelay = computeInitialDelay(interval, startTime);
+      if (interval <= 0 || initialDelay < 0) {
+        return Optional.empty();
+      }
+      return Optional.of(create(interval, initialDelay));
+    }
+
+    static Schedule create(long interval, long initialDelay) {
+      return new AutoValue_ScheduleConfig_Schedule(interval, initialDelay);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.java
new file mode 100644
index 0000000..022b0e1
--- /dev/null
+++ b/java/com/google/gerrit/server/git/DefaultAdvertiseRefsHook.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.git;
+
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import java.util.Map;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.AbstractAdvertiseRefsHook;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+
+/**
+ * Wrapper around {@link com.google.gerrit.server.permissions.PermissionBackend.ForProject} that
+ * implements {@link org.eclipse.jgit.transport.AdvertiseRefsHook}.
+ */
+public class DefaultAdvertiseRefsHook extends AbstractAdvertiseRefsHook {
+
+  private final PermissionBackend.ForProject perm;
+  private final PermissionBackend.RefFilterOptions opts;
+
+  public DefaultAdvertiseRefsHook(
+      PermissionBackend.ForProject perm, PermissionBackend.RefFilterOptions opts) {
+    this.perm = perm;
+    this.opts = opts;
+  }
+
+  @Override
+  protected Map<String, Ref> getAdvertisedRefs(Repository repo, RevWalk revWalk)
+      throws ServiceMayNotContinueException {
+    try {
+      return perm.filter(repo.getAllRefs(), repo, opts);
+    } catch (PermissionBackendException e) {
+      ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
+      ex.initCause(e);
+      throw ex;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/GarbageCollectionRunner.java b/java/com/google/gerrit/server/git/GarbageCollectionRunner.java
index ea93f96..e4316c5 100644
--- a/java/com/google/gerrit/server/git/GarbageCollectionRunner.java
+++ b/java/com/google/gerrit/server/git/GarbageCollectionRunner.java
@@ -14,23 +14,17 @@
 
 package com.google.gerrit.server.git;
 
-import static com.google.gerrit.server.config.ScheduleConfig.MISSING_CONFIG;
-
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.server.config.GcConfig;
-import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Runnable to enable scheduling gc to run periodically */
 public class GarbageCollectionRunner implements Runnable {
   private static final Logger gcLog = LoggerFactory.getLogger(GarbageCollection.LOG_NAME);
-  private static final Logger log = LoggerFactory.getLogger(GarbageCollectionRunner.class);
 
   static class Lifecycle implements LifecycleListener {
     private final WorkQueue queue;
@@ -46,20 +40,7 @@
 
     @Override
     public void start() {
-      ScheduleConfig scheduleConfig = gcConfig.getScheduleConfig();
-      long interval = scheduleConfig.getInterval();
-      long delay = scheduleConfig.getInitialDelay();
-      if (delay == MISSING_CONFIG && interval == MISSING_CONFIG) {
-        log.info("Ignoring missing gc schedule configuration");
-      } else if (delay < 0 || interval <= 0) {
-        log.warn(String.format("Ignoring invalid gc schedule configuration: %s", scheduleConfig));
-      } else {
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError =
-            queue
-                .getDefaultQueue()
-                .scheduleAtFixedRate(gcRunner, delay, interval, TimeUnit.MILLISECONDS);
-      }
+      gcConfig.getSchedule().ifPresent(s -> queue.scheduleAtFixedRate(gcRunner, s));
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java b/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
index cee08d4..938f6cf 100644
--- a/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
+++ b/java/com/google/gerrit/server/git/MergeIdenticalTreeException.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 
 /**
  * Indicates that the commit is already contained in destination branch. Either the commit itself is
  * in the source tree, or the content is merged
  */
-public class MergeIdenticalTreeException extends RestApiException {
+public class MergeIdenticalTreeException extends ResourceConflictException {
   private static final long serialVersionUID = 1L;
 
   /** @param msg message to return to the client describing the error. */
diff --git a/java/com/google/gerrit/server/git/MergeOp.java b/java/com/google/gerrit/server/git/MergeOp.java
index 114e1da..b4a66f4 100644
--- a/java/com/google/gerrit/server/git/MergeOp.java
+++ b/java/com/google/gerrit/server/git/MergeOp.java
@@ -328,8 +328,7 @@
     return allowClosed ? SUBMIT_RULE_OPTIONS_ALLOW_CLOSED : SUBMIT_RULE_OPTIONS;
   }
 
-  private static List<SubmitRecord> getSubmitRecords(ChangeData cd, boolean allowClosed)
-      throws OrmException {
+  private static List<SubmitRecord> getSubmitRecords(ChangeData cd, boolean allowClosed) {
     return cd.submitRecords(submitRuleOptions(allowClosed));
   }
 
@@ -393,7 +392,7 @@
     commitStatus.maybeFailVerbose();
   }
 
-  private void bypassSubmitRules(ChangeSet cs, boolean allowClosed) throws OrmException {
+  private void bypassSubmitRules(ChangeSet cs, boolean allowClosed) {
     checkArgument(
         !cs.furtherHiddenChanges(), "cannot bypass submit rules for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
@@ -834,7 +833,7 @@
     }
   }
 
-  private SubmitType getSubmitType(ChangeData cd) throws OrmException {
+  private SubmitType getSubmitType(ChangeData cd) {
     SubmitTypeRecord str = cd.submitTypeRecord();
     return str.isOk() ? str.type : null;
   }
diff --git a/java/com/google/gerrit/server/git/ProjectConfig.java b/java/com/google/gerrit/server/git/ProjectConfig.java
index abef5a8..2dc01b4 100644
--- a/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -154,6 +154,8 @@
   private static final String EXTENSION_PANELS = "extension-panels";
   private static final String KEY_PANEL = "panel";
 
+  private static final Pattern EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN = Pattern.compile("[, \t]{1,}");
+
   private Project.NameKey projectName;
   private Project project;
   private AccountsSection accountsSection;
@@ -678,7 +680,7 @@
         AccessSection as = getAccessSection(refName, true);
 
         for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
-          for (String n : varName.split("[, \t]{1,}")) {
+          for (String n : Splitter.on(EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN).split(varName)) {
             n = convertLegacyPermission(n);
             if (isPermission(n)) {
               as.getPermission(n, true).setExclusiveGroup(true);
diff --git a/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
index 0822161..d346997 100644
--- a/java/com/google/gerrit/server/git/TagCache.java
+++ b/java/com/google/gerrit/server/git/TagCache.java
@@ -82,7 +82,7 @@
     }
   }
 
-  TagSetHolder get(Project.NameKey name) {
+  public TagSetHolder get(Project.NameKey name) {
     EntryVal val = cache.getIfPresent(name.get());
     if (val == null) {
       synchronized (createLock) {
diff --git a/java/com/google/gerrit/server/git/TagMatcher.java b/java/com/google/gerrit/server/git/TagMatcher.java
index 6e46d76..945e91e 100644
--- a/java/com/google/gerrit/server/git/TagMatcher.java
+++ b/java/com/google/gerrit/server/git/TagMatcher.java
@@ -23,7 +23,7 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-class TagMatcher {
+public class TagMatcher {
   final BitSet mask = new BitSet();
   final List<Ref> newRefs = new ArrayList<>();
   final List<LostRef> lostRefs = new ArrayList<>();
@@ -50,7 +50,7 @@
     this.updated = updated;
   }
 
-  boolean isReachable(Ref tagRef) {
+  public boolean isReachable(Ref tagRef) {
     tagRef = db.peel(tagRef);
 
     ObjectId tagObj = tagRef.getPeeledObjectId();
diff --git a/java/com/google/gerrit/server/git/TagSetHolder.java b/java/com/google/gerrit/server/git/TagSetHolder.java
index e1faa65..3f08d10 100644
--- a/java/com/google/gerrit/server/git/TagSetHolder.java
+++ b/java/com/google/gerrit/server/git/TagSetHolder.java
@@ -21,7 +21,7 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
-class TagSetHolder {
+public class TagSetHolder {
   private final Object buildLock = new Object();
   private final Project.NameKey projectName;
   private volatile TagSet tags;
@@ -42,7 +42,7 @@
     this.tags = tags;
   }
 
-  TagMatcher matcher(TagCache cache, Repository db, Collection<Ref> include) {
+  public TagMatcher matcher(TagCache cache, Repository db, Collection<Ref> include) {
     include = include.stream().filter(r -> !TagSet.skip(r)).collect(toList());
 
     TagSet tags = this.tags;
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 0adb45a..11e149c 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -130,6 +131,15 @@
     return executor;
   }
 
+  /** Executes a periodic command at a fixed schedule on the default queue. */
+  public void scheduleAtFixedRate(Runnable command, Schedule schedule) {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        getDefaultQueue()
+            .scheduleAtFixedRate(
+                command, schedule.initialDelay(), schedule.interval(), TimeUnit.MILLISECONDS);
+  }
+
   /** Get all of the tasks currently scheduled in any work queue. */
   public List<Task<?>> getTasks() {
     final List<Task<?>> r = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 6eba282..597bce1 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -23,13 +23,14 @@
 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.DefaultAdvertiseRefsHook;
 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.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ContributorAgreementsChecker;
@@ -183,7 +184,6 @@
   AsyncReceiveCommits(
       ReceiveCommits.Factory factory,
       PermissionBackend permissionBackend,
-      VisibleRefFilter.Factory refFilterFactory,
       Provider<InternalChangeQuery> queryProvider,
       @ReceiveCommitsExecutor ExecutorService executor,
       RequestScopePropagator scopePropagator,
@@ -236,7 +236,8 @@
     List<AdvertiseRefsHook> advHooks = new ArrayList<>(4);
     allRefsWatcher = new AllRefsWatcher();
     advHooks.add(allRefsWatcher);
-    advHooks.add(refFilterFactory.create(projectState, repo).setShowMetadata(false));
+    advHooks.add(
+        new DefaultAdvertiseRefsHook(perm, RefFilterOptions.builder().setFilterMeta(true).build()));
     advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
     advHooks.add(new HackPushNegotiateHook());
     rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index caf3a91..0e54e59 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -838,6 +838,11 @@
         continue;
       }
 
+      if (!projectState.getProject().getState().permitsWrite()) {
+        reject(cmd, "prohibited by Gerrit: project state does not permit write");
+        return;
+      }
+
       if (MagicBranch.isMagicBranch(cmd.getRefName())) {
         parseMagicBranch(cmd);
         continue;
@@ -1070,9 +1075,6 @@
     } catch (AuthException err) {
       ok = false;
     }
-    if (!projectState.statePermitsWrite()) {
-      reject(cmd, "prohibited by Gerrit: project state does not permit write");
-    }
     if (ok) {
       if (isHead(cmd) && !isCommit(cmd)) {
         return;
@@ -1169,9 +1171,6 @@
       if (!validRefOperation(cmd)) {
         return;
       }
-      if (!projectState.statePermitsWrite()) {
-        cmd.setResult(REJECTED_NONFASTFORWARD, " project state does not permit write.");
-      }
       actualCommands.add(cmd);
     } else {
       cmd.setResult(
@@ -1519,10 +1518,6 @@
 
     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);
@@ -1570,10 +1565,6 @@
         reject(cmd, e.getMessage());
         return;
       }
-      if (!projectState.statePermitsWrite()) {
-        reject(cmd, "project state does not permit write");
-        return;
-      }
     }
 
     RevWalk walk = rp.getRevWalk();
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index 4661987..5462631 100644
--- a/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -101,7 +101,7 @@
       throws IOException, ConfigInvalidException {
     rw.reset();
     AccountConfig accountConfig = new AccountConfig(accountId, repo);
-    accountConfig.setEagerParsing(true).load(rw, commit);
+    accountConfig.load(rw, commit);
     if (messages != null) {
       messages.addAll(
           accountConfig
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index d5db92f..632eb47 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -20,7 +20,9 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
@@ -225,6 +227,7 @@
         messages.addAll(commitValidator.onCommitReceived(receiveEvent));
       }
     } catch (CommitValidationException e) {
+      log.debug("CommitValidationException occurred: {}", e.getFullMessage(), e);
       // Keep the old messages (and their order) in case of an exception
       messages.addAll(e.getMessages());
       throw new CommitValidationException(e.getMessage(), messages);
@@ -333,9 +336,7 @@
       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] : "";
-
+        String lastLine = Iterables.getLast(Splitter.on('\n').split(c.getFullMessage()), "");
         if (lastLine.indexOf(CHANGE_ID_PREFIX) == -1) {
           sb.append('\n');
           sb.append('\n');
diff --git a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
new file mode 100644
index 0000000..d30945f
--- /dev/null
+++ b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
@@ -0,0 +1,154 @@
+// 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.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.GroupReference;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ScheduleConfig;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.group.db.GroupNameNotes;
+import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Runnable to schedule periodic group reindexing.
+ *
+ * <p>Periodic group indexing is intended to run only on slaves. Replication to slaves happens on
+ * Git level so that Gerrit is not aware of incoming replication events. But slaves need an updated
+ * group index to resolve memberships of users for ACL validation. To keep the group index in slaves
+ * up-to-date this class periodically scans the group refs in the All-Users repository to reindex
+ * groups if they are stale. The ref states of the group refs are cached so that on each run deleted
+ * groups can be detected and reindexed. This means callers of slaves may observe outdated group
+ * information until the next indexing happens. The interval on which group indexing is done is
+ * configurable by setting {@code index.scheduledIndexer.interval} in {@code gerrit.config}. By
+ * default group indexing is done every 5 minutes.
+ *
+ * <p>This class is not able to detect group deletions that were replicated while the slave was
+ * offline. This means if group refs are deleted while the slave is offline these groups are not
+ * removed from the group index when the slave is started. However since group deletion is not
+ * supported this should never happen and one can always do an offline reindex before starting the
+ * slave.
+ */
+public class PeriodicGroupIndexer implements Runnable {
+  private static final Logger log = LoggerFactory.getLogger(PeriodicGroupIndexer.class);
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(Lifecycle.class);
+    }
+  }
+
+  private static class Lifecycle implements LifecycleListener {
+    private final Config cfg;
+    private final WorkQueue queue;
+    private final PeriodicGroupIndexer runner;
+
+    @Inject
+    Lifecycle(@GerritServerConfig Config cfg, WorkQueue queue, PeriodicGroupIndexer runner) {
+      this.cfg = cfg;
+      this.queue = queue;
+      this.runner = runner;
+    }
+
+    @Override
+    public void start() {
+      boolean runOnStartup = cfg.getBoolean("index", "scheduledIndexer", "runOnStartup", true);
+      if (runOnStartup) {
+        runner.run();
+      }
+
+      boolean isEnabled = cfg.getBoolean("index", "scheduledIndexer", "enabled", true);
+      if (!isEnabled) {
+        log.warn("index.scheduledIndexer is disabled");
+        return;
+      }
+
+      Schedule schedule =
+          ScheduleConfig.builder(cfg, "index")
+              .setSubsection("scheduledIndexer")
+              .buildSchedule()
+              .orElseGet(() -> Schedule.createOrFail(TimeUnit.MINUTES.toMillis(5), "00:00"));
+      queue.scheduleAtFixedRate(runner, schedule);
+    }
+
+    @Override
+    public void stop() {
+      // handled by WorkQueue.stop() already
+    }
+  }
+
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager repoManager;
+  private final Provider<GroupIndexer> groupIndexerProvider;
+
+  private ImmutableSet<AccountGroup.UUID> groupUuids;
+
+  @Inject
+  PeriodicGroupIndexer(
+      AllUsersName allUsersName,
+      GitRepositoryManager repoManager,
+      Provider<GroupIndexer> groupIndexerProvider) {
+    this.allUsersName = allUsersName;
+    this.repoManager = repoManager;
+    this.groupIndexerProvider = groupIndexerProvider;
+  }
+
+  @Override
+  public synchronized void run() {
+    try (Repository allUsers = repoManager.openRepository(allUsersName)) {
+      ImmutableSet<AccountGroup.UUID> newGroupUuids =
+          GroupNameNotes.loadAllGroups(allUsers)
+              .stream()
+              .map(GroupReference::getUUID)
+              .collect(toImmutableSet());
+      GroupIndexer groupIndexer = groupIndexerProvider.get();
+      int reindexCounter = 0;
+      for (AccountGroup.UUID groupUuid : newGroupUuids) {
+        if (groupIndexer.reindexIfStale(groupUuid)) {
+          reindexCounter++;
+        }
+      }
+      if (groupUuids != null) {
+        // Check if any group was deleted since the last run and if yes remove these groups from the
+        // index.
+        for (AccountGroup.UUID groupUuid : Sets.difference(groupUuids, newGroupUuids)) {
+          groupIndexer.index(groupUuid);
+          reindexCounter++;
+        }
+      }
+      groupUuids = newGroupUuids;
+      log.info("Run group indexer, {} groups reindexed", reindexCounter);
+    } catch (Throwable t) {
+      log.error("Failed to reindex groups", t);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index d0ea9ca..bbdc8d2 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -78,23 +78,6 @@
   }
 
   /**
-   * 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
@@ -210,7 +193,7 @@
    * @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)
+  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);
@@ -229,52 +212,13 @@
    * @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 {
+  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.
    *
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index f52ea23..a3aae83 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -51,12 +51,14 @@
 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.index.group.GroupIndexer;
 import com.google.gerrit.server.notedb.GroupsMigration;
 import com.google.gerrit.server.update.RefUpdateUtil;
 import com.google.gerrit.server.update.RetryHelper;
 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.sql.Timestamp;
@@ -102,6 +104,7 @@
   private final AllUsersName allUsersName;
   private final GroupCache groupCache;
   private final GroupIncludeCache groupIncludeCache;
+  private final Provider<GroupIndexer> indexer;
   private final AuditService auditService;
   private final RenameGroupOp.Factory renameGroupOpFactory;
   @Nullable private final IdentifiedUser currentUser;
@@ -120,6 +123,7 @@
       GroupBackend groupBackend,
       GroupCache groupCache,
       GroupIncludeCache groupIncludeCache,
+      Provider<GroupIndexer> indexer,
       AuditService auditService,
       AccountCache accountCache,
       RenameGroupOp.Factory renameGroupOpFactory,
@@ -135,6 +139,7 @@
     this.allUsersName = allUsersName;
     this.groupCache = groupCache;
     this.groupIncludeCache = groupIncludeCache;
+    this.indexer = indexer;
     this.auditService = auditService;
     this.renameGroupOpFactory = renameGroupOpFactory;
     this.groupsMigration = groupsMigration;
@@ -476,7 +481,8 @@
     }
   }
 
-  private InternalGroup createGroupInNoteDb(
+  @VisibleForTesting
+  public InternalGroup createGroupInNoteDb(
       InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
       throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
@@ -595,7 +601,7 @@
   }
 
   private void updateCachesOnGroupCreation(InternalGroup createdGroup) throws IOException {
-    groupCache.onCreateGroup(createdGroup.getGroupUUID());
+    indexer.get().index(createdGroup.getGroupUUID());
     for (Account.Id modifiedMember : createdGroup.getMembers()) {
       groupIncludeCache.evictGroupsWithMember(modifiedMember);
     }
@@ -607,7 +613,7 @@
   private void updateCachesOnGroupUpdate(UpdateResult result) throws IOException {
     if (result.getPreviousGroupName().isPresent()) {
       AccountGroup.NameKey previousName = result.getPreviousGroupName().get();
-      groupCache.evictAfterRename(previousName);
+      groupCache.evict(previousName);
 
       // TODO(aliceks): After switching to NoteDb, consider to use a BatchRefUpdate.
       @SuppressWarnings("unused")
@@ -620,7 +626,10 @@
                   result.getGroupName().get())
               .start(0, TimeUnit.MILLISECONDS);
     }
-    groupCache.evict(result.getGroupUuid(), result.getGroupId(), result.getGroupName());
+    groupCache.evict(result.getGroupUuid());
+    groupCache.evict(result.getGroupId());
+    groupCache.evict(result.getGroupName());
+    indexer.get().index(result.getGroupUuid());
 
     result.getAddedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
     result.getDeletedMembers().forEach(groupIncludeCache::evictGroupsWithMember);
diff --git a/java/com/google/gerrit/server/index/DummyIndexModule.java b/java/com/google/gerrit/server/index/DummyIndexModule.java
index 85d6a7c..211f9d1 100644
--- a/java/com/google/gerrit/server/index/DummyIndexModule.java
+++ b/java/com/google/gerrit/server/index/DummyIndexModule.java
@@ -59,7 +59,7 @@
 
   @Override
   protected void configure() {
-    install(new IndexModule(1));
+    install(new IndexModule(1, true));
     bind(IndexConfig.class).toInstance(IndexConfig.createDefault());
     bind(Index.class).toInstance(new DummyChangeIndex());
     bind(AccountIndex.Factory.class).toInstance(new DummyAccountIndexFactory());
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index cb6dc3c..0c4a988 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -92,9 +92,11 @@
   private final ListeningExecutorService interactiveExecutor;
   private final ListeningExecutorService batchExecutor;
   private final boolean closeExecutorsOnShutdown;
+  private final boolean slave;
 
-  public IndexModule(int threads) {
+  public IndexModule(int threads, boolean slave) {
     this.threads = threads;
+    this.slave = slave;
     this.interactiveExecutor = null;
     this.batchExecutor = null;
     this.closeExecutorsOnShutdown = true;
@@ -106,6 +108,7 @@
     this.interactiveExecutor = interactiveExecutor;
     this.batchExecutor = batchExecutor;
     this.closeExecutorsOnShutdown = false;
+    slave = false;
   }
 
   @Override
@@ -151,6 +154,11 @@
       ChangeIndexDefinition changes,
       GroupIndexDefinition groups,
       ProjectIndexDefinition projects) {
+    if (slave) {
+      // In slave mode, we only have the group index.
+      return ImmutableList.of(groups);
+    }
+
     Collection<IndexDefinition<?, ?, ?>> result =
         ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups, changes, projects);
     Set<String> expected =
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexer.java b/java/com/google/gerrit/server/index/account/AccountIndexer.java
index dd24714..91fa1d9 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexer.java
@@ -25,4 +25,12 @@
    * @param id account id to index.
    */
   void index(Account.Id id) throws IOException;
+
+  /**
+   * Synchronously reindex an account if it is stale.
+   *
+   * @param id account id to index.
+   * @return whether the account was reindexed
+   */
+  boolean reindexIfStale(Account.Id id) throws IOException;
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index 8ca4a34..d055a46 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -35,7 +35,6 @@
 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;
 
@@ -102,6 +101,15 @@
     autoReindexIfStale(id);
   }
 
+  @Override
+  public boolean reindexIfStale(Account.Id id) throws IOException {
+    if (stalenessChecker.isStale(id)) {
+      index(id);
+      return true;
+    }
+    return false;
+  }
+
   private static boolean autoReindexIfStale(Config cfg) {
     return cfg.getBoolean("index", null, "autoReindexIfStale", true);
   }
@@ -110,7 +118,7 @@
     if (autoReindexIfStale) {
       // Don't retry indefinitely; if this fails the account will be stale.
       @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError = reindexIfStale(id);
+      Future<?> possiblyIgnoredError = reindexIfStaleAsync(id);
     }
   }
 
@@ -124,19 +132,11 @@
    * @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(
+  private com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStaleAsync(
       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);
+        Futures.nonCancellationPropagating(batchExecutor.submit(() -> reindexIfStale(id))),
+        IndexUtils.MAPPER);
   }
 
   private void fireAccountIndexedEvent(int id) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 1fbe3f0..344048f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -256,39 +256,49 @@
     return state.toString() + ',' + adr;
   }
 
-  public static ReviewerSet parseReviewerFieldValues(Iterable<String> values) {
+  public static ReviewerSet parseReviewerFieldValues(Change.Id changeId, Iterable<String> values) {
     ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
         ImmutableTable.builder();
     for (String v : values) {
 
       int i = v.indexOf(',');
       if (i < 0) {
-        log.error("Invalid value for reviewer field: %s", v);
+        log.warn("Invalid value for reviewer field from change {}: {}", changeId.get(), v);
         continue;
       }
 
       int i2 = v.lastIndexOf(',');
       if (i2 == i) {
-        log.error("Invalid value for reviewer field: %s", v);
+        // Don't log a warning here.
+        // For each reviewer we store 2 values in the reviewer field, one value with the format
+        // "<reviewer-type>,<account-id>" and one value with the format
+        // "<reviewer-type>,<account-id>,<timestamp>" (see #getReviewerFieldValues(ReviewerSet)).
+        // For parsing we are only interested in the "<reviewer-type>,<account-id>,<timestamp>"
+        // value and the "<reviewer-type>,<account-id>" value is ignored here.
         continue;
       }
 
       com.google.common.base.Optional<ReviewerStateInternal> reviewerState =
           Enums.getIfPresent(ReviewerStateInternal.class, v.substring(0, i));
       if (!reviewerState.isPresent()) {
-        log.error("Failed to parse reviewer state from reviewer field: %s", v);
+        log.warn(
+            "Failed to parse reviewer state of reviewer field from change {}: {}",
+            changeId.get(),
+            v);
         continue;
       }
 
       Optional<Account.Id> accountId = Account.Id.tryParse(v.substring(i + 1, i2));
       if (!accountId.isPresent()) {
-        log.error("Failed to parse account ID from reviewer field: %s", v);
+        log.warn(
+            "Failed to parse account ID of reviewer field from change {}: {}", changeId.get(), v);
         continue;
       }
 
       Long l = Longs.tryParse(v.substring(i2 + 1, v.length()));
       if (l == null) {
-        log.error("Failed to parse timestamp from reviewer field: %s", v);
+        log.warn(
+            "Failed to parse timestamp of reviewer field from change {}: {}", changeId.get(), v);
         continue;
       }
       Timestamp timestamp = new Timestamp(l);
@@ -298,37 +308,53 @@
     return ReviewerSet.fromTable(b.build());
   }
 
-  public static ReviewerByEmailSet parseReviewerByEmailFieldValues(Iterable<String> values) {
+  public static ReviewerByEmailSet parseReviewerByEmailFieldValues(
+      Change.Id changeId, Iterable<String> values) {
     ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
     for (String v : values) {
       int i = v.indexOf(',');
       if (i < 0) {
-        log.error("Invalid value for reviewer by email field: %s", v);
+        log.warn("Invalid value for reviewer by email field from change {}: {}", changeId.get(), v);
         continue;
       }
 
       int i2 = v.lastIndexOf(',');
       if (i2 == i) {
-        log.error("Invalid value for reviewer by email field: %s", v);
+        // Don't log a warning here.
+        // For each reviewer we store 2 values in the reviewer field, one value with the format
+        // "<reviewer-type>,<email>" and one value with the format
+        // "<reviewer-type>,<email>,<timestamp>" (see
+        // #getReviewerByEmailFieldValues(ReviewerByEmailSet)).
+        // For parsing we are only interested in the "<reviewer-type>,<email>,<timestamp>" value
+        // and the "<reviewer-type>,<email>" value is ignored here.
         continue;
       }
 
       com.google.common.base.Optional<ReviewerStateInternal> reviewerState =
           Enums.getIfPresent(ReviewerStateInternal.class, v.substring(0, i));
       if (!reviewerState.isPresent()) {
-        log.error("Failed to parse reviewer state from reviewer by email field: %s", v);
+        log.warn(
+            "Failed to parse reviewer state of reviewer by email field from change {}: {}",
+            changeId.get(),
+            v);
         continue;
       }
 
       Address address = Address.tryParse(v.substring(i + 1, i2));
       if (address == null) {
-        log.error("Failed to parse address from reviewer by email field: %s", v);
+        log.warn(
+            "Failed to parse address of reviewer by email field from change {}: {}",
+            changeId.get(),
+            v);
         continue;
       }
 
       Long l = Longs.tryParse(v.substring(i2 + 1, v.length()));
       if (l == null) {
-        log.error("Failed to parse timestamp from reviewer by email field: %s", v);
+        log.warn(
+            "Failed to parse timestamp of reviewer by email field from change {}: {}",
+            changeId.get(),
+            v);
         continue;
       }
       Timestamp timestamp = new Timestamp(l);
@@ -703,8 +729,7 @@
     return Lists.transform(records, r -> GSON.toJson(new StoredSubmitRecord(r)).getBytes(UTF_8));
   }
 
-  private static Iterable<byte[]> storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts)
-      throws OrmException {
+  private static Iterable<byte[]> storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts) {
     return storedSubmitRecords(cd.submitRecords(opts));
   }
 
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index e95470d..cf51197 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -34,6 +34,7 @@
 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.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -53,6 +54,7 @@
 import java.util.concurrent.Callable;
 import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -430,11 +432,23 @@
 
     @Override
     public Boolean callImpl(Provider<ReviewDb> db) throws Exception {
-      if (!stalenessChecker.isStale(id)) {
-        return false;
+      try {
+        if (stalenessChecker.isStale(id)) {
+          index(newChangeData(db.get(), project, id));
+          return true;
+        }
+      } catch (NoSuchChangeException nsce) {
+        log.debug("Change {} was deleted, aborting reindexing the change.", id.get());
+      } catch (Exception e) {
+        if (!isCausedByRepositoryNotFoundException(e)) {
+          throw e;
+        }
+        log.debug(
+            "Change {} belongs to deleted project {}, aborting reindexing the change.",
+            id.get(),
+            project.get());
       }
-      index(newChangeData(db.get(), project, id));
-      return true;
+      return false;
     }
 
     @Override
@@ -443,6 +457,16 @@
     }
   }
 
+  private boolean isCausedByRepositoryNotFoundException(Throwable throwable) {
+    while (throwable != null) {
+      if (throwable instanceof RepositoryNotFoundException) {
+        return true;
+      }
+      throwable = throwable.getCause();
+    }
+    return false;
+  }
+
   // 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
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index aca4eab..47dad94 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -98,11 +98,7 @@
           executor.submit(
               () -> {
                 try {
-                  Optional<InternalGroup> oldGroup = groupCache.get(uuid);
-                  if (oldGroup.isPresent()) {
-                    InternalGroup group = oldGroup.get();
-                    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
-                  }
+                  groupCache.evict(uuid);
                   Optional<InternalGroup> internalGroup = groupCache.get(uuid);
                   if (internalGroup.isPresent()) {
                     index.replace(internalGroup.get());
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexer.java b/java/com/google/gerrit/server/index/group/GroupIndexer.java
index 0925cf4..503fd6b 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexer.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexer.java
@@ -25,4 +25,12 @@
    * @param uuid group UUID to index.
    */
   void index(AccountGroup.UUID uuid) throws IOException;
+
+  /**
+   * Synchronously reindex a group if it is stale.
+   *
+   * @param uuid group UUID to index.
+   * @return whether the group was reindexed
+   */
+  boolean reindexIfStale(AccountGroup.UUID uuid) throws IOException;
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index b101dcb..a1da4ea 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -35,7 +35,6 @@
 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;
 
@@ -91,6 +90,8 @@
   @Override
   public void index(AccountGroup.UUID uuid) throws IOException {
     for (Index<AccountGroup.UUID, InternalGroup> i : getWriteIndexes()) {
+      // Evict the cache to get an up-to-date value for sure.
+      groupCache.evict(uuid);
       Optional<InternalGroup> internalGroup = groupCache.get(uuid);
       if (internalGroup.isPresent()) {
         i.replace(internalGroup.get());
@@ -102,6 +103,15 @@
     autoReindexIfStale(uuid);
   }
 
+  @Override
+  public boolean reindexIfStale(AccountGroup.UUID uuid) throws IOException {
+    if (stalenessChecker.isStale(uuid)) {
+      index(uuid);
+      return true;
+    }
+    return false;
+  }
+
   private static boolean autoReindexIfStale(Config cfg) {
     return cfg.getBoolean("index", null, "autoReindexIfStale", true);
   }
@@ -110,7 +120,7 @@
     if (autoReindexIfStale) {
       // Don't retry indefinitely; if this fails the group will be stale.
       @SuppressWarnings("unused")
-      Future<?> possiblyIgnoredError = reindexIfStale(uuid);
+      Future<?> possiblyIgnoredError = reindexIfStaleAsync(uuid);
     }
   }
 
@@ -124,19 +134,11 @@
    * @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(
+  private com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStaleAsync(
       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);
+        Futures.nonCancellationPropagating(batchExecutor.submit(() -> reindexIfStale(uuid))),
+        IndexUtils.MAPPER);
   }
 
   private void fireGroupIndexedEvent(String uuid) {
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
index 418bb35..94e1be7 100644
--- a/java/com/google/gerrit/server/index/group/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -72,9 +72,6 @@
     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));
diff --git a/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java b/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
new file mode 100644
index 0000000..481c2e9
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/AutoReplyMailFilter.java
@@ -0,0 +1,55 @@
+// 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.mail;
+
+import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Filters out auto-reply messages according to RFC 3834. */
+@Singleton
+public class AutoReplyMailFilter implements MailFilter {
+
+  private static final Logger log = LoggerFactory.getLogger(AutoReplyMailFilter.class);
+
+  @Override
+  public boolean shouldProcessMessage(MailMessage message) {
+    for (String header : message.additionalHeaders()) {
+      if (header.startsWith(MailHeader.PRECEDENCE.fieldWithDelimiter())) {
+        String prec = header.substring(MailHeader.PRECEDENCE.fieldWithDelimiter().length()).trim();
+
+        if (prec.equals("list") || prec.equals("junk") || prec.equals("bulk")) {
+          log.error(
+              "Message %s has a Precedence header. Will ignore and delete message.", message.id());
+          return false;
+        }
+
+      } else if (header.startsWith(MailHeader.AUTO_SUBMITTED.fieldWithDelimiter())) {
+        String autoSubmitted =
+            header.substring(MailHeader.AUTO_SUBMITTED.fieldWithDelimiter().length()).trim();
+
+        if (!autoSubmitted.equals("no")) {
+          log.error(
+              "Message %s has an Auto-Submitted header. Will ignore and delete message.",
+              message.id());
+          return false;
+        }
+      }
+    }
+
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/MailHeader.java b/java/com/google/gerrit/server/mail/MailHeader.java
new file mode 100644
index 0000000..be12288
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/MailHeader.java
@@ -0,0 +1,71 @@
+// 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.mail;
+
+/** Variables used by emails to hold data */
+public enum MailHeader {
+  // Gerrit metadata holders
+  ASSIGNEE("Gerrit-Assignee"),
+  BRANCH("Gerrit-Branch"),
+  CC("Gerit-CC"),
+  COMMENT_IN_REPLY_TO("Comment-In-Reply-To"),
+  COMMENT_DATE("Gerrit-Comment-Date"),
+  CHANGE_ID("Gerrit-Change-Id"),
+  CHANGE_NUMBER("Gerrit-Change-Number"),
+  CHANGE_URL("Gerrit-ChangeURL"),
+  COMMIT("Gerrit-Commit"),
+  HAS_COMMENTS("Gerrit-HasComments"),
+  HAS_LABELS("Gerrit-Has-Labels"),
+  MESSAGE_TYPE("Gerrit-MessageType"),
+  OWNER("Gerrit-Owner"),
+  PATCH_SET("Gerrit-PatchSet"),
+  PROJECT("Gerrit-Project"),
+  REVIEWER("Gerrit-Reviewer"),
+
+  // Commonly used Email headers
+  AUTO_SUBMITTED("Auto-Submitted"),
+  PRECEDENCE("Precedence"),
+  REFERENCES("References");
+
+  private final String name;
+  private final String fieldName;
+
+  MailHeader(String name) {
+    boolean customHeader = name.startsWith("Gerrit-");
+    this.name = name;
+
+    if (customHeader) {
+      this.fieldName = "X-" + name;
+    } else {
+      this.fieldName = name;
+    }
+  }
+
+  public String fieldWithDelimiter() {
+    return fieldName() + ": ";
+  }
+
+  public String withDelimiter() {
+    return name + ": ";
+  }
+
+  public String fieldName() {
+    return fieldName;
+  }
+
+  public String getName() {
+    return name;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/MetadataName.java b/java/com/google/gerrit/server/mail/MetadataName.java
deleted file mode 100644
index 3080e4f..0000000
--- a/java/com/google/gerrit/server/mail/MetadataName.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail;
-
-public final class MetadataName {
-  public static final String CHANGE_NUMBER = "Gerrit-Change-Number";
-  public static final String PATCH_SET = "Gerrit-PatchSet";
-  public static final String MESSAGE_TYPE = "Gerrit-MessageType";
-  public static final String TIMESTAMP = "Gerrit-Comment-Date";
-
-  public static String toHeader(String metadataName) {
-    return "X-" + metadataName;
-  }
-
-  public static String toHeaderWithDelimiter(String metadataName) {
-    return toHeader(metadataName) + ": ";
-  }
-
-  public static String toFooterWithDelimiter(String metadataName) {
-    return metadataName + ": ";
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/receive/MailHeaderParser.java b/java/com/google/gerrit/server/mail/receive/MailHeaderParser.java
new file mode 100644
index 0000000..05525bd
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/receive/MailHeaderParser.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.mail.receive;
+
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.mail.MailHeader;
+import com.google.gerrit.server.mail.MailUtil;
+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 MailHeaderParser {
+  private static final Logger log = LoggerFactory.getLogger(MailHeaderParser.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(MailHeader.CHANGE_NUMBER.fieldWithDelimiter())) {
+        String num = header.substring(MailHeader.CHANGE_NUMBER.fieldWithDelimiter().length());
+        metadata.changeNumber = Ints.tryParse(num);
+      } else if (header.startsWith(MailHeader.PATCH_SET.fieldWithDelimiter())) {
+        String ps = header.substring(MailHeader.PATCH_SET.fieldWithDelimiter().length());
+        metadata.patchSet = Ints.tryParse(ps);
+      } else if (header.startsWith(MailHeader.COMMENT_DATE.fieldWithDelimiter())) {
+        String ts = header.substring(MailHeader.COMMENT_DATE.fieldWithDelimiter().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(MailHeader.MESSAGE_TYPE.fieldWithDelimiter())) {
+        metadata.messageType =
+            header.substring(MailHeader.MESSAGE_TYPE.fieldWithDelimiter().length());
+      }
+    }
+    if (metadata.hasRequiredFields()) {
+      return metadata;
+    }
+
+    // If the required fields were not yet found, continue to parse the text
+    if (!Strings.isNullOrEmpty(m.textContent())) {
+      Iterable<String> lines = Splitter.on('\n').split(m.textContent().replace("\r\n", "\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())) {
+      Iterable<String> lines = Splitter.on("</div>").split(m.htmlContent().replace("\r\n", "\n"));
+      extractFooters(lines, metadata, m);
+      if (metadata.hasRequiredFields()) {
+        return metadata;
+      }
+    }
+
+    return metadata;
+  }
+
+  private static void extractFooters(Iterable<String> lines, MailMetadata metadata, MailMessage m) {
+    for (String line : lines) {
+      if (metadata.changeNumber == null && line.contains(MailHeader.CHANGE_NUMBER.getName())) {
+        metadata.changeNumber =
+            Ints.tryParse(extractFooter(MailHeader.CHANGE_NUMBER.withDelimiter(), line));
+      } else if (metadata.patchSet == null && line.contains(MailHeader.PATCH_SET.getName())) {
+        metadata.patchSet =
+            Ints.tryParse(extractFooter(MailHeader.PATCH_SET.withDelimiter(), line));
+      } else if (metadata.timestamp == null && line.contains(MailHeader.COMMENT_DATE.getName())) {
+        String ts = extractFooter(MailHeader.COMMENT_DATE.withDelimiter(), 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(MailHeader.MESSAGE_TYPE.getName())) {
+        metadata.messageType = extractFooter(MailHeader.MESSAGE_TYPE.withDelimiter(), 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/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 3f794e8d0..9917261 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.MailFilter;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -77,6 +78,7 @@
   private static final Logger log = LoggerFactory.getLogger(MailProcessor.class);
 
   private final Emails emails;
+  private final InboundEmailRejectionSender.Factory emailRejectionSender;
   private final RetryHelper retryHelper;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
@@ -94,6 +96,7 @@
   @Inject
   public MailProcessor(
       Emails emails,
+      InboundEmailRejectionSender.Factory emailRejectionSender,
       RetryHelper retryHelper,
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
@@ -108,6 +111,7 @@
       AccountCache accountCache,
       @CanonicalWebUrl Provider<String> canonicalUrl) {
     this.emails = emails;
+    this.emailRejectionSender = emailRejectionSender;
     this.retryHelper = retryHelper;
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
@@ -148,21 +152,29 @@
       }
     }
 
-    MailMetadata metadata = MetadataParser.parse(message);
+    MailMetadata metadata = MailHeaderParser.parse(message);
+
     if (!metadata.hasRequiredFields()) {
       log.error(
           String.format(
               "Message %s is missing required metadata, have %s. Will delete message.",
               message.id(), metadata));
+      sendRejectionEmail(message, InboundEmailRejectionSender.Error.PARSING_ERROR);
       return;
     }
 
     Set<Account.Id> accountIds = emails.getAccountFor(metadata.author);
+
     if (accountIds.size() != 1) {
       log.error(
           String.format(
               "Address %s could not be matched to a unique account. It was matched to %s. Will delete message.",
               metadata.author, accountIds));
+
+      // We don't want to send an email if no accounts are linked to it.
+      if (accountIds.size() > 1) {
+        sendRejectionEmail(message, InboundEmailRejectionSender.Error.UNKNOWN_ACCOUNT);
+      }
       return;
     }
     Account.Id accountId = accountIds.iterator().next();
@@ -173,12 +185,23 @@
     }
     if (!accountState.get().getAccount().isActive()) {
       log.warn(String.format("Mail: Account %s is inactive. Will delete message.", accountId));
+      sendRejectionEmail(message, InboundEmailRejectionSender.Error.INACTIVE_ACCOUNT);
       return;
     }
 
     persistComments(buf, message, metadata, accountId);
   }
 
+  private void sendRejectionEmail(MailMessage message, InboundEmailRejectionSender.Error reason) {
+    try {
+      InboundEmailRejectionSender em =
+          emailRejectionSender.create(message.from(), message.id(), reason);
+      em.send();
+    } catch (Exception e) {
+      log.error("Cannot send email to warn for an error", e);
+    }
+  }
+
   private void persistComments(
       BatchUpdate.Factory buf, MailMessage message, MailMetadata metadata, Account.Id sender)
       throws OrmException, UpdateException, RestApiException {
@@ -190,6 +213,8 @@
             String.format(
                 "Message %s references unique change %s, but there are %d matching changes in the index. Will delete message.",
                 message.id(), metadata.changeNumber, changeDataList.size()));
+
+        sendRejectionEmail(message, InboundEmailRejectionSender.Error.INTERNAL_EXCEPTION);
         return;
       }
       ChangeData cd = changeDataList.get(0);
@@ -221,6 +246,7 @@
         log.warn(
             String.format(
                 "Could not parse any comments from %s. Will delete message.", message.id()));
+        sendRejectionEmail(message, InboundEmailRejectionSender.Error.PARSING_ERROR);
         return;
       }
 
diff --git a/java/com/google/gerrit/server/mail/receive/MetadataParser.java b/java/com/google/gerrit/server/mail/receive/MetadataParser.java
deleted file mode 100644
index 88c54f9..0000000
--- a/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(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
index b8309ab..e770a3e 100644
--- a/java/com/google/gerrit/server/mail/receive/ParserUtil.java
+++ b/java/com/google/gerrit/server/mail/receive/ParserUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.receive;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.Comment;
 import java.util.List;
@@ -37,34 +38,34 @@
    */
   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]);
+    List<String> lines = Splitter.on('\n').splitToList(comment);
+    for (int i = 0; i < lines.size() - 2; i++) {
+      j.add(lines.get(i));
     }
 
     // Check if the last line contains the full quotation pattern (date + email)
-    String lastLine = lines[lines.length - 1];
+    String lastLine = lines.get(lines.size() - 1);
     if (containsQuotationPattern(lastLine)) {
-      if (lines.length > 1) {
-        j.add(lines[lines.length - 2]);
+      if (lines.size() > 1) {
+        j.add(lines.get(lines.size() - 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 (lines.size() > 1) {
+      String lastLines = lines.get(lines.size() - 2) + lastLine;
       if (containsQuotationPattern(lastLines)) {
         return j.toString().trim();
       }
     }
 
     // Add the last two lines
-    if (lines.length > 1) {
-      j.add(lines[lines.length - 2]);
+    if (lines.size() > 1) {
+      j.add(lines.get(lines.size() - 2));
     }
-    j.add(lines[lines.length - 1]);
+    j.add(lines.get(lines.size() - 1));
 
     return j.toString().trim();
   }
diff --git a/java/com/google/gerrit/server/mail/receive/TextParser.java b/java/com/google/gerrit/server/mail/receive/TextParser.java
index 80443ba..b99c608 100644
--- a/java/com/google/gerrit/server/mail/receive/TextParser.java
+++ b/java/com/google/gerrit/server/mail/receive/TextParser.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.receive;
 
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.PeekingIterator;
@@ -63,11 +64,10 @@
 
     PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
 
-    String[] lines = body.split("\n");
     MailComment currentComment = null;
     String lastEncounteredFileName = null;
     Comment lastEncounteredComment = null;
-    for (String line : lines) {
+    for (String line : Splitter.on('\n').split(body)) {
       if (line.equals(">")) {
         // Skip empty lines
         continue;
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index cad15fa..29fc04f 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
+import com.google.gerrit.server.mail.MailHeader;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
@@ -56,6 +57,7 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
+import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.Repository;
@@ -158,7 +160,7 @@
     }
 
     if (patchSet != null) {
-      setHeader("X-Gerrit-PatchSet", patchSet.getPatchSetId() + "");
+      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.getPatchSetId() + "");
       if (patchSetInfo == null) {
         try {
           patchSetInfo =
@@ -178,11 +180,11 @@
 
     super.init();
     if (timestamp != null) {
-      setHeader("Date", new Date(timestamp.getTime()));
+      setHeader(FieldName.DATE, new Date(timestamp.getTime()));
     }
     setChangeSubjectHeader();
-    setHeader("X-Gerrit-Change-Id", "" + change.getKey().get());
-    setHeader("X-Gerrit-Change-Number", "" + change.getChangeId());
+    setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
+    setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
     setChangeUrlHeader();
     setCommitIdHeader();
 
@@ -202,7 +204,7 @@
   private void setChangeUrlHeader() {
     final String u = getChangeUrl();
     if (u != null) {
-      setHeader("X-Gerrit-ChangeURL", "<" + u + ">");
+      setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
     }
   }
 
@@ -211,12 +213,12 @@
         && patchSet.getRevision() != null
         && patchSet.getRevision().get() != null
         && patchSet.getRevision().get().length() > 0) {
-      setHeader("X-Gerrit-Commit", patchSet.getRevision().get());
+      setHeader(MailHeader.COMMIT.fieldName(), patchSet.getRevision().get());
     }
   }
 
   private void setChangeSubjectHeader() {
-    setHeader("Subject", textTemplate("ChangeSubject"));
+    setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
   }
 
   /** Get a link to the change; null if the server doesn't know its own address. */
@@ -481,19 +483,18 @@
     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()));
+    footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
+    footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + Integer.toString(change.getChangeId()));
+    footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.getPatchSetId());
+    footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
     if (change.getAssignee() != null) {
-      footers.add("Gerrit-Assignee: " + getNameEmailFor(change.getAssignee()));
+      footers.add(MailHeader.ASSIGNEE.withDelimiter() + getNameEmailFor(change.getAssignee()));
     }
     for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
-      footers.add("Gerrit-Reviewer: " + reviewer);
+      footers.add(MailHeader.REVIEWER.withDelimiter() + reviewer);
     }
     for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
-      footers.add("Gerrit-CC: " + reviewer);
+      footers.add(MailHeader.CC.withDelimiter() + reviewer);
     }
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/CommentFormatter.java b/java/com/google/gerrit/server/mail/send/CommentFormatter.java
index 74d1480..2590505 100644
--- a/java/com/google/gerrit/server/mail/send/CommentFormatter.java
+++ b/java/com/google/gerrit/server/mail/send/CommentFormatter.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Strings.isNullOrEmpty;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -52,7 +53,7 @@
     }
 
     List<Block> result = new ArrayList<>();
-    for (String p : source.split("\n\n")) {
+    for (String p : Splitter.on("\n\n").split(source)) {
       if (isQuote(p)) {
         result.add(makeQuote(p));
       } else if (isPreFormat(p)) {
@@ -96,7 +97,7 @@
     boolean inList = false;
     boolean inParagraph = false;
 
-    for (String line : p.split("\n")) {
+    for (String line : Splitter.on('\n').split(p)) {
       if (line.startsWith("-") || line.startsWith("*")) {
         // The next line looks like a list item. If not building a list already,
         // then create one. Remove the list item marker (* or -) from the line.
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 5df0d62..b04dcd6 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.MailHeader;
 import com.google.gerrit.server.mail.MailUtil;
 import com.google.gerrit.server.mail.receive.Protocol;
 import com.google.gerrit.server.patch.PatchFile;
@@ -54,6 +55,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 import org.slf4j.Logger;
@@ -163,14 +165,14 @@
 
     // Add header that enables identifying comments on parsed email.
     // Grouping is currently done by timestamp.
-    setHeader("X-Gerrit-Comment-Date", timestamp);
+    setHeader(MailHeader.COMMENT_DATE.fieldName(), timestamp);
 
     if (incomingEmailEnabled) {
       if (replyToAddress == null) {
         // Remove Reply-To and use outbound SMTP (default) instead.
-        removeHeader("Reply-To");
+        removeHeader(FieldName.REPLY_TO);
       } else {
-        setHeader("Reply-To", replyToAddress);
+        setHeader(FieldName.REPLY_TO, replyToAddress);
       }
     }
   }
@@ -523,12 +525,12 @@
     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"));
+    footers.add(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
+    footers.add(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
+    footers.add(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
 
     for (Account.Id account : getReplyAccounts()) {
-      footers.add("Gerrit-Comment-In-Reply-To: " + getNameEmailFor(account));
+      footers.add(MailHeader.COMMENT_IN_REPLY_TO.withDelimiter() + getNameEmailFor(account));
     }
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
new file mode 100644
index 0000000..5143dc7
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.MailHeader;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import org.apache.james.mime4j.dom.field.FieldName;
+
+/** Send an email to inform users that parsing their inbound email failed. */
+public class InboundEmailRejectionSender extends OutgoingEmail {
+
+  /** Used by the templating system to determine what error message should be sent */
+  public enum Error {
+    PARSING_ERROR,
+    INACTIVE_ACCOUNT,
+    UNKNOWN_ACCOUNT,
+    INTERNAL_EXCEPTION;
+  }
+
+  public interface Factory {
+    InboundEmailRejectionSender create(Address to, String threadId, Error reason);
+  }
+
+  private final Address to;
+  private final Error reason;
+  private final String threadId;
+
+  @Inject
+  public InboundEmailRejectionSender(
+      EmailArguments ea, @Assisted Address to, @Assisted String threadId, @Assisted Error reason) {
+    super(ea, "error");
+    this.to = checkNotNull(to);
+    this.threadId = checkNotNull(threadId);
+    this.reason = checkNotNull(reason);
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+    setListIdHeader();
+    setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
+
+    add(RecipientType.TO, to);
+
+    if (!threadId.isEmpty()) {
+      setHeader(MailHeader.REFERENCES.fieldName(), threadId);
+    }
+  }
+
+  private void setListIdHeader() {
+    // Set a reasonable list id so that filters can be used to sort messages
+    setHeader("List-Id", "<gerrit-noreply." + getGerritHost() + ">");
+    if (getSettingsUrl() != null) {
+      setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
+    }
+  }
+
+  @Override
+  protected void format() throws EmailException {
+    appendText(textTemplate("InboundEmailRejection_" + reason.name()));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("InboundEmailRejectionHtml_" + reason.name()));
+    }
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    footers.add(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
+  }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java b/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
index b267275..8d7df41 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoyTofuProvider.java
@@ -51,6 +51,8 @@
     "DeleteReviewerHtml.soy",
     "DeleteVote.soy",
     "DeleteVoteHtml.soy",
+    "InboundEmailRejection.soy",
+    "InboundEmailRejectionHtml.soy",
     "Footer.soy",
     "FooterHtml.soy",
     "HeaderHtml.soy",
@@ -58,6 +60,8 @@
     "MergedHtml.soy",
     "NewChange.soy",
     "NewChangeHtml.soy",
+    "NoReplyFooter.soy",
+    "NoReplyFooterHtml.soy",
     "Private.soy",
     "RegisterNewEmail.soy",
     "ReplacePatchSet.soy",
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 3fefac4..f657fb0 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.MailHeader;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gwtorm.server.OrmException;
 import java.util.HashMap;
@@ -115,7 +116,7 @@
     branchData.put("shortName", branch.getShortName());
     soyContext.put("branch", branchData);
 
-    footers.add("Gerrit-Project: " + branch.getParentKey().get());
+    footers.add(MailHeader.PROJECT.withDelimiter() + 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
index 2d5c13bd..b882089 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -29,6 +29,7 @@
 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.MailHeader;
 import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
@@ -49,6 +50,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.StringJoiner;
+import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.util.SystemReader;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -57,9 +59,6 @@
 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;
@@ -163,7 +162,7 @@
       // 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")) {
+      if (!headers.containsKey(FieldName.REPLY_TO)) {
         StringJoiner j = new StringJoiner(", ");
         if (fromId != null) {
           Address address = toAddress(fromId);
@@ -173,7 +172,7 @@
         }
         smtpRcptTo.stream().forEach(a -> j.add(a.getEmail()));
         smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.getEmail()));
-        setHeader("Reply-To", j.toString());
+        setHeader(FieldName.REPLY_TO, j.toString());
       }
 
       String textPart = textBody.toString();
@@ -208,13 +207,13 @@
         Map<String, EmailHeader> shallowCopy = new HashMap<>();
         shallowCopy.putAll(headers);
         // Remove To and Cc
-        shallowCopy.remove(HDR_TO);
-        shallowCopy.remove(HDR_CC);
+        shallowCopy.remove(FieldName.TO);
+        shallowCopy.remove(FieldName.CC);
         for (Address a : smtpRcptToPlaintextOnly) {
           // Add new To
           EmailHeader.AddressList to = new EmailHeader.AddressList();
           to.add(a);
-          shallowCopy.put(HDR_TO, to);
+          shallowCopy.put(FieldName.TO, to);
         }
         args.emailSender.send(va.smtpFromAddress, smtpRcptToPlaintextOnly, shallowCopy, va.body);
       }
@@ -233,17 +232,19 @@
     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", "");
+    setHeader(FieldName.DATE, new Date());
+    headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
+    headers.put(FieldName.TO, new EmailHeader.AddressList());
+    headers.put(FieldName.CC, new EmailHeader.AddressList());
+    setHeader(FieldName.MESSAGE_ID, "");
+    setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : accountsToNotify.keySet()) {
       add(recipientType, accountsToNotify.get(recipientType));
     }
 
-    setHeader("X-Gerrit-MessageType", messageClass);
+    setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
+    footers.add(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
     textBody = new StringBuilder();
     htmlBody = new StringBuilder();
 
@@ -500,15 +501,15 @@
           if (!override) {
             return;
           }
-          ((EmailHeader.AddressList) headers.get(HDR_TO)).remove(addr.getEmail());
-          ((EmailHeader.AddressList) headers.get(HDR_CC)).remove(addr.getEmail());
+          ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.getEmail());
+          ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.getEmail());
         }
         switch (rt) {
           case TO:
-            ((EmailHeader.AddressList) headers.get(HDR_TO)).add(addr);
+            ((EmailHeader.AddressList) headers.get(FieldName.TO)).add(addr);
             break;
           case CC:
-            ((EmailHeader.AddressList) headers.get(HDR_CC)).add(addr);
+            ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr);
             break;
           case BCC:
             break;
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 3e36de9..b9f5fe6 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -619,7 +619,6 @@
     } else {
       // OpenRepo buffers objects separately; caller may assume that objects are available in the
       // inserter it previously passed via setChangeRepo.
-      checkState(saveObjects, "cannot use dryrun with saveObjects = false");
       or.flushToFinalInserter();
     }
 
diff --git a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
index c8d69b9..8e8f232 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
@@ -48,9 +48,11 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfigProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -68,11 +70,13 @@
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
+import com.google.gerrit.server.update.RefUpdateUtil;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -80,10 +84,8 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
-import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
@@ -92,14 +94,17 @@
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.internal.storage.file.PackInserter;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.io.NullOutputStream;
 import org.slf4j.Logger;
@@ -131,6 +136,8 @@
   public static class Builder {
     private final Config cfg;
     private final SitePaths sitePaths;
+    private final Provider<PersonIdent> serverIdent;
+    private final AllUsersName allUsers;
     private final SchemaFactory<ReviewDb> schemaFactory;
     private final GitRepositoryManager repoManager;
     private final NoteDbUpdateManager.Factory updateManagerFactory;
@@ -158,6 +165,8 @@
     Builder(
         GerritServerConfigProvider configProvider,
         SitePaths sitePaths,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        AllUsersName allUsers,
         SchemaFactory<ReviewDb> schemaFactory,
         GitRepositoryManager repoManager,
         NoteDbUpdateManager.Factory updateManagerFactory,
@@ -175,6 +184,8 @@
       // trial/autoMigrate get set correctly below.
       this.cfg = configProvider.get();
       this.sitePaths = sitePaths;
+      this.serverIdent = serverIdent;
+      this.allUsers = allUsers;
       this.schemaFactory = schemaFactory;
       this.repoManager = repoManager;
       this.updateManagerFactory = updateManagerFactory;
@@ -337,6 +348,8 @@
       return new NoteDbMigrator(
           sitePaths,
           schemaFactory,
+          serverIdent,
+          allUsers,
           repoManager,
           updateManagerFactory,
           bundleReader,
@@ -364,6 +377,8 @@
   private final FileBasedConfig gerritConfig;
   private final FileBasedConfig noteDbConfig;
   private final SchemaFactory<ReviewDb> schemaFactory;
+  private final Provider<PersonIdent> serverIdent;
+  private final AllUsersName allUsers;
   private final GitRepositoryManager repoManager;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final ChangeBundleReader bundleReader;
@@ -388,6 +403,8 @@
   private NoteDbMigrator(
       SitePaths sitePaths,
       SchemaFactory<ReviewDb> schemaFactory,
+      Provider<PersonIdent> serverIdent,
+      AllUsersName allUsers,
       GitRepositoryManager repoManager,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeBundleReader bundleReader,
@@ -416,6 +433,8 @@
     }
 
     this.schemaFactory = schemaFactory;
+    this.serverIdent = serverIdent;
+    this.allUsers = allUsers;
     this.rebuilder = rebuilder;
     this.repoManager = repoManager;
     this.updateManagerFactory = updateManagerFactory;
@@ -691,10 +710,9 @@
     Stopwatch sw = Stopwatch.createStarted();
     log.info("Rebuilding changes in NoteDb");
 
+    ImmutableListMultimap<Project.NameKey, Change.Id> changesByProject = getChangesByProject();
     List<ListenableFuture<Boolean>> futures = new ArrayList<>();
     try (ContextHelper contextHelper = new ContextHelper()) {
-      ImmutableListMultimap<Project.NameKey, Change.Id> changesByProject =
-          getChangesByProject(contextHelper.getReviewDb());
       List<Project.NameKey> projectNames =
           Ordering.usingToString().sortedCopy(changesByProject.keySet());
       for (Project.NameKey project : projectNames) {
@@ -723,7 +741,7 @@
     }
   }
 
-  private ImmutableListMultimap<Project.NameKey, Change.Id> getChangesByProject(ReviewDb db)
+  private ImmutableListMultimap<Project.NameKey, Change.Id> getChangesByProject()
       throws OrmException {
     // Memoize all changes so we can close the db connection and allow other threads to use the full
     // connection pool.
@@ -731,13 +749,15 @@
         MultimapBuilder.treeKeys(comparing(Project.NameKey::get))
             .treeSetValues(comparing(Change.Id::get))
             .build();
-    if (!projects.isEmpty()) {
-      return byProject(db.changes().all(), c -> projects.contains(c.getProject()), out);
+    try (ReviewDb db = unwrapDb(schemaFactory.open())) {
+      if (!projects.isEmpty()) {
+        return byProject(db.changes().all(), c -> projects.contains(c.getProject()), out);
+      }
+      if (!changes.isEmpty()) {
+        return byProject(db.changes().get(changes), c -> true, out);
+      }
+      return byProject(db.changes().all(), c -> true, out);
     }
-    if (!changes.isEmpty()) {
-      return byProject(db.changes().get(changes), c -> true, out);
-    }
-    return byProject(db.changes().all(), c -> true, out);
   }
 
   private static ImmutableListMultimap<Project.NameKey, Change.Id> byProject(
@@ -767,59 +787,63 @@
         new TextProgressMonitor(
             new PrintWriter(new BufferedWriter(new OutputStreamWriter(progressOut, UTF_8))));
     try (Repository changeRepo = repoManager.openRepository(project);
+        // Only use a PackInserter for the change repo, not All-Users.
+        //
+        // It's not possible to share a single inserter for All-Users across all project tasks, and
+        // we don't want to add one pack per project to All-Users. Adding many loose objects is
+        // preferable to many packs.
+        //
+        // Anyway, the number of objects inserted into All-Users is proportional to the number
+        // of pending draft comments, which should not be high (relative to the total number of
+        // changes), so the number of loose objects shouldn't be too unreasonable.
         ObjectInserter changeIns = newPackInserter(changeRepo);
         ObjectReader changeReader = changeIns.newReader();
         RevWalk changeRw = new RevWalk(changeReader);
-        NoteDbUpdateManager manager =
-            updateManagerFactory
-                .create(project)
-                .setSaveObjects(false)
-                .setAtomicRefUpdates(false)
-                // Only use a PackInserter for the change repo, not All-Users.
-                //
-                // It's not possible to share a single inserter for All-Users across all project
-                // tasks, and we don't want to add one pack per project to All-Users. Adding many
-                // loose objects is preferable to many packs.
-                //
-                // Anyway, the number of objects inserted into All-Users is proportional to the
-                // number of pending draft comments, which should not be high (relative to the total
-                // number of changes), so the number of loose objects shouldn't be too unreasonable.
-                .setChangeRepo(
-                    changeRepo, changeRw, changeIns, new ChainedReceiveCommands(changeRepo))) {
-      Set<Change.Id> skipExecute = new HashSet<>();
+        Repository allUsersRepo = repoManager.openRepository(allUsers);
+        ObjectInserter allUsersIns = allUsersRepo.newObjectInserter();
+        ObjectReader allUsersReader = allUsersIns.newReader();
+        RevWalk allUsersRw = new RevWalk(allUsersReader)) {
+      ChainedReceiveCommands changeCmds = new ChainedReceiveCommands(changeRepo);
+      ChainedReceiveCommands allUsersCmds = new ChainedReceiveCommands(allUsersRepo);
+
       Collection<Change.Id> changes = allChanges.get(project);
       pm.beginTask(FormatUtil.elide("Rebuilding " + project.get(), 50), changes.size());
+      int toSave = 0;
       try {
         for (Change.Id changeId : changes) {
-          boolean staged = false;
-          try {
-            stage(db, changeId, manager);
-            staged = true;
+          // NoteDbUpdateManager assumes that all commands in its OpenRepo were added by itself, so
+          // we can't share the top-level ChainedReceiveCommands. Use a new set of commands sharing
+          // the same underlying repo, and copy commands back to the top-level
+          // ChainedReceiveCommands later. This also assumes that each ref in the final list of
+          // commands was only modified by a single NoteDbUpdateManager; since we use one manager
+          // per change, and each ref corresponds to exactly one change, this assumption should be
+          // safe.
+          ChainedReceiveCommands tmpChangeCmds =
+              new ChainedReceiveCommands(changeCmds.getRepoRefCache());
+          ChainedReceiveCommands tmpAllUsersCmds =
+              new ChainedReceiveCommands(allUsersCmds.getRepoRefCache());
+
+          try (NoteDbUpdateManager manager =
+              updateManagerFactory
+                  .create(project)
+                  .setAtomicRefUpdates(false)
+                  .setSaveObjects(false)
+                  .setChangeRepo(changeRepo, changeRw, changeIns, tmpChangeCmds)
+                  .setAllUsersRepo(allUsersRepo, allUsersRw, allUsersIns, tmpAllUsersCmds)) {
+            rebuild(db, changeId, manager);
+
+            // Executing with dryRun=true writes all objects to the underlying inserters and adds
+            // commands to the ChainedReceiveCommands. Afterwards, we can discard the manager, so we
+            // don't keep using any memory beyond what may be buffered in the PackInserter.
+            manager.execute(true);
+
+            tmpChangeCmds.getCommands().values().forEach(c -> addCommand(changeCmds, c));
+            tmpAllUsersCmds.getCommands().values().forEach(c -> addCommand(allUsersCmds, c));
+
+            toSave++;
           } catch (NoPatchSetsException e) {
             log.warn(e.getMessage());
-          } catch (Throwable t) {
-            log.error("Failed to rebuild change " + changeId, t);
-            ok = false;
-          }
-          pm.update(1);
-          if (!staged) {
-            skipExecute.add(changeId);
-          }
-        }
-      } finally {
-        pm.endTask();
-      }
-
-      pm.beginTask(
-          FormatUtil.elide("Saving " + project.get(), 50), changes.size() - skipExecute.size());
-      try {
-        for (Change.Id changeId : changes) {
-          if (skipExecute.contains(changeId)) {
-            continue;
-          }
-          try {
-            rebuilder.execute(db, changeId, manager, true, false);
-          } catch (ConflictingUpdateException e) {
+          } catch (ConflictingUpdateException ex) {
             log.warn(
                 "Rebuilding detected a conflicting ReviewDb update for change {};"
                     + " will be auto-rebuilt at runtime",
@@ -834,15 +858,23 @@
         pm.endTask();
       }
 
+      pm.beginTask(FormatUtil.elide("Saving " + project.get(), 50), ProgressMonitor.UNKNOWN);
       try {
-        manager.execute();
+        save(changeRepo, changeRw, changeIns, changeCmds);
+        save(allUsersRepo, allUsersRw, allUsersIns, allUsersCmds);
+        // This isn't really useful progress. If we passed a real ProgressMonitor to
+        // BatchRefUpdate#execute we might get something more incremental, but that doesn't allow us
+        // to specify the repo name in the task text.
+        pm.update(toSave);
       } catch (LockFailureException e) {
         log.warn(
             "Rebuilding detected a conflicting NoteDb update for the following refs, which will"
                 + " be auto-rebuilt at runtime: {}",
             e.getFailedRefs().stream().distinct().sorted().collect(joining(", ")));
-      } catch (OrmException | IOException e) {
+      } catch (IOException e) {
         log.error("Failed to save NoteDb state for " + project, e);
+      } finally {
+        pm.endTask();
       }
     } catch (RepositoryNotFoundException e) {
       log.warn("Repository {} not found", project);
@@ -852,7 +884,7 @@
     return ok;
   }
 
-  private void stage(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
+  private void rebuild(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
       throws OrmException, IOException {
     // Match ChangeRebuilderImpl#stage, but without calling manager.stage(), since that can only be
     // called after building updates for all changes.
@@ -863,6 +895,31 @@
       throw new NoSuchChangeException(changeId);
     }
     rebuilder.buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
+
+    rebuilder.execute(db, changeId, manager, true, false);
+  }
+
+  private static void addCommand(ChainedReceiveCommands cmds, ReceiveCommand cmd) {
+    // ChainedReceiveCommands doesn't allow no-ops, but these occur when rebuilding a
+    // previously-rebuilt change.
+    if (!cmd.getOldId().equals(cmd.getNewId())) {
+      cmds.add(cmd);
+    }
+  }
+
+  private void save(Repository repo, RevWalk rw, ObjectInserter ins, ChainedReceiveCommands cmds)
+      throws IOException {
+    if (cmds.isEmpty()) {
+      return;
+    }
+    ins.flush();
+    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+    bru.setRefLogMessage("Migrate changes to NoteDb", false);
+    bru.setRefLogIdent(serverIdent.get());
+    bru.setAtomic(false);
+    bru.setAllowNonFastForwards(true);
+    cmds.addTo(bru);
+    RefUpdateUtil.executeChecked(bru, rw);
   }
 
   private static boolean futuresToBoolean(List<ListenableFuture<Boolean>> futures, String errMsg) {
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 01d5fa3..800d877 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -37,6 +37,7 @@
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 @Singleton
@@ -159,18 +160,21 @@
     }
 
     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);
+      Optional<Boolean> r = user.get(IS_ADMIN);
+      if (r.isPresent()) {
+        return r.get();
       }
-      return r;
+
+      boolean isAdmin;
+      if (user.isImpersonating()) {
+        isAdmin = false;
+      } else if (user instanceof PeerDaemonUser) {
+        isAdmin = true;
+      } else {
+        isAdmin = allow(capabilities().administrateServer);
+      }
+      user.put(IS_ADMIN, isAdmin);
+      return isAdmin;
     }
 
     private boolean canEmailReviewers() {
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index d9a43a9..f3a3c78 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -28,8 +28,9 @@
   public static class LegacyControlsModule extends FactoryModule {
     @Override
     protected void configure() {
-      // TODO(sop) Hide ProjectControl, RefControl, ChangeControl related bindings.
+      // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
       factory(ProjectControl.Factory.class);
+      factory(DefaultRefFilter.Factory.class);
       bind(ChangeControl.Factory.class);
     }
   }
diff --git a/java/com/google/gerrit/server/git/VisibleRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
similarity index 74%
rename from java/com/google/gerrit/server/git/VisibleRefFilter.java
rename to java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 3efca3f..28695b8 100644
--- a/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.git;
+package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
@@ -21,6 +21,7 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_SELF;
 import static java.util.stream.Collectors.toMap;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -33,15 +34,13 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.TagMatcher;
 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.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
@@ -58,67 +57,56 @@
 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);
+class DefaultRefFilter {
+  private static final Logger log = LoggerFactory.getLogger(DefaultRefFilter.class);
 
-  public interface Factory {
-    VisibleRefFilter create(ProjectState projectState, Repository git);
+  interface Factory {
+    DefaultRefFilter create(ProjectControl projectControl);
   }
 
   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 ProjectControl projectControl;
+  private final CurrentUser user;
   private final ProjectState projectState;
-  private final Repository git;
-  private boolean showMetadata = true;
-  private String userEditPrefix;
+  private final PermissionBackend.ForProject permissionBackendForProject;
+
   private Map<Change.Id, Branch.NameKey> visibleChanges;
 
   @Inject
-  VisibleRefFilter(
+  DefaultRefFilter(
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
       @Nullable SearchingChangeCacheImpl changeCache,
       Provider<ReviewDb> db,
-      Provider<CurrentUser> user,
       GroupCache groupCache,
       PermissionBackend permissionBackend,
-      @Assisted ProjectState projectState,
-      @Assisted Repository git) {
+      @Assisted ProjectControl projectControl) {
     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;
+    this.projectControl = projectControl;
+
+    this.user = projectControl.getUser();
+    this.projectState = projectControl.getProjectState();
+    this.permissionBackendForProject =
+        permissionBackend.user(user).database(db).project(projectState.getNameKey());
   }
 
-  /** 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) {
+  Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+      throws PermissionBackendException {
     if (projectState.isAllUsers()) {
       refs = addUsersSelfSymref(refs);
     }
@@ -128,7 +116,7 @@
     if (!projectState.isAllUsers()) {
       if (checkProjectPermission(forProject, ProjectPermission.READ)) {
         return refs;
-      } else if (checkProjectPermission(forProject, ProjectPermission.READ_NO_CONFIG)) {
+      } else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
         return fastHideRefsMetaConfig(refs);
       }
     }
@@ -137,12 +125,11 @@
     boolean isAdmin;
     Account.Id userId;
     IdentifiedUser identifiedUser;
-    if (user.get().isIdentifiedUser()) {
+    if (user.isIdentifiedUser()) {
       viewMetadata = withUser.testOrFalse(GlobalPermission.ACCESS_DATABASE);
       isAdmin = withUser.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
-      identifiedUser = user.get().asIdentifiedUser();
+      identifiedUser = user.asIdentifiedUser();
       userId = identifiedUser.getAccountId();
-      userEditPrefix = RefNames.refsEditPrefix(userId);
     } else {
       viewMetadata = false;
       isAdmin = false;
@@ -158,16 +145,16 @@
       Change.Id changeId;
       Account.Id accountId;
       AccountGroup.UUID accountGroupUuid;
-      if (name.startsWith(REFS_CACHE_AUTOMERGE) || (!showMetadata && isMetadata(name))) {
+      if (name.startsWith(REFS_CACHE_AUTOMERGE) || (opts.filterMeta() && isMetadata(name))) {
         continue;
       } else if (RefNames.isRefsEdit(name)) {
         // Edits are visible only to the owning user, if change is visible.
-        if (viewMetadata || visibleEdit(name)) {
+        if (viewMetadata || visibleEdit(repo, 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)) {
+        if (viewMetadata || visible(repo, changeId)) {
           result.put(name, ref);
         }
       } else if ((accountId = Account.Id.fromRef(name)) != null) {
@@ -218,14 +205,20 @@
     // 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)) {
+    if (!deferredTags.isEmpty() && (!result.isEmpty() || opts.filterTagsSeparately())) {
       TagMatcher tags =
           tagCache
               .get(projectState.getNameKey())
               .matcher(
                   tagCache,
-                  git,
-                  filterTagsSeparately ? filter(git.getAllRefs()).values() : result.values());
+                  repo,
+                  opts.filterTagsSeparately()
+                      ? filter(
+                              repo.getAllRefs(),
+                              repo,
+                              opts.toBuilder().setFilterTagsSeparately(false).build())
+                          .values()
+                      : result.values());
       for (Ref tag : deferredTags) {
         if (tags.isReachable(tag)) {
           result.put(tag.getName(), tag);
@@ -246,8 +239,8 @@
   }
 
   private Map<String, Ref> addUsersSelfSymref(Map<String, Ref> refs) {
-    if (user.get().isIdentifiedUser()) {
-      Ref r = refs.get(RefNames.refsUsers(user.get().getAccountId()));
+    if (user.isIdentifiedUser()) {
+      Ref r = refs.get(RefNames.refsUsers(user.getAccountId()));
       if (r != null) {
         SymbolicRef s = new SymbolicRef(REFS_USERS_SELF, r);
         refs = new HashMap<>(refs);
@@ -257,28 +250,10 @@
     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) {
+  private boolean visible(Repository repo, Change.Id changeId) {
     if (visibleChanges == null) {
       if (changeCache == null) {
-        visibleChanges = visibleChangesByScan();
+        visibleChanges = visibleChangesByScan(repo);
       } else {
         visibleChanges = visibleChangesBySearch();
       }
@@ -286,22 +261,26 @@
     return visibleChanges.containsKey(changeId);
   }
 
-  private boolean visibleEdit(String name) {
+  private boolean visibleEdit(Repository repo, String name) {
     Change.Id id = Change.Id.fromEditRefPart(name);
     // Initialize if it wasn't yet
     if (visibleChanges == null) {
-      visible(id);
+      visible(repo, id);
     }
     if (id == null) {
       return false;
     }
-    if (userEditPrefix != null && name.startsWith(userEditPrefix) && visible(id)) {
+    if (user.isIdentifiedUser()
+        && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
+        && visible(repo, 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);
+        permissionBackendForProject
+            .ref(visibleChanges.get(id).get())
+            .check(RefPermission.READ_PRIVATE_CHANGES);
         return true;
       } catch (PermissionBackendException | AuthException e) {
         return false;
@@ -317,7 +296,7 @@
       for (ChangeData cd : changeCache.getChangeData(db.get(), project)) {
         ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
         if (projectState.statePermitsRead()
-            && perm.indexedChange(cd, notes).test(ChangePermission.READ)) {
+            && permissionBackendForProject.indexedChange(cd, notes).test(ChangePermission.READ)) {
           visibleChanges.put(cd.getId(), cd.change().getDest());
         }
       }
@@ -329,32 +308,34 @@
     }
   }
 
-  private Map<Change.Id, Branch.NameKey> visibleChangesByScan() {
+  private Map<Change.Id, Branch.NameKey> visibleChangesByScan(Repository repo) {
     Project.NameKey p = projectState.getNameKey();
     Stream<ChangeNotesResult> s;
     try {
-      s = changeNotesFactory.scan(git, db.get(), p);
+      s = changeNotesFactory.scan(repo, 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))
+    return s.map(r -> toNotes(r))
         .filter(Objects::nonNull)
         .collect(toMap(n -> n.getChangeId(), n -> n.getChange().getDest()));
   }
 
   @Nullable
-  private ChangeNotes toNotes(Project.NameKey p, ChangeNotesResult r) {
+  private ChangeNotes toNotes(ChangeNotesResult r) {
     if (r.error().isPresent()) {
-      log.warn("Failed to load change " + r.id() + " in " + p, r.error().get());
+      log.warn(
+          "Failed to load change " + r.id() + " in " + projectState.getName(), r.error().get());
       return null;
     }
     try {
-      if (projectState.statePermitsRead() && perm.change(r.notes()).test(ChangePermission.READ)) {
+      if (projectState.statePermitsRead()
+          && permissionBackendForProject.change(r.notes()).test(ChangePermission.READ)) {
         return r.notes();
       }
     } catch (PermissionBackendException e) {
-      log.warn("Failed to check permission for " + r.id() + " in " + p, e);
+      log.warn("Failed to check permission for " + r.id() + " in " + projectState.getName(), e);
     }
     return null;
   }
@@ -373,7 +354,7 @@
 
   private boolean canReadRef(String ref) {
     try {
-      perm.ref(ref).check(RefPermission.READ);
+      permissionBackendForProject.ref(ref).check(RefPermission.READ);
     } catch (AuthException e) {
       return false;
     } catch (PermissionBackendException e) {
@@ -392,8 +373,7 @@
     } catch (PermissionBackendException e) {
       log.error(
           String.format(
-              "Can't check permission for user %s on project %s",
-              user.get(), projectState.getName()),
+              "Can't check permission for user %s on project %s", user, projectState.getName()),
           e);
       return false;
     }
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 5144833..209de69 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -20,10 +20,14 @@
 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.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Provider;
 import java.util.Collection;
+import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 /**
  * Helpers for {@link PermissionBackend} that must fail.
@@ -103,6 +107,12 @@
         throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
+
+    @Override
+    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
   }
 
   private static class FailedRef extends ForRef {
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 0c4f228..56c300d 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
@@ -36,7 +37,10 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.Iterator;
+import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -305,6 +309,45 @@
     public BooleanCondition testCond(ProjectPermission perm) {
       return new PermissionBackendCondition.ForProject(this, perm);
     }
+
+    /**
+     * @return a partition of the provided refs that are visible to the user that this instance is
+     *     scoped to.
+     */
+    public abstract Map<String, Ref> filter(
+        Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+        throws PermissionBackendException;
+  }
+
+  /** Options for filtering refs using {@link ForProject}. */
+  @AutoValue
+  public abstract static class RefFilterOptions {
+    /** Remove all NoteDb refs (refs/changes/*, refs/users/*, edit refs) from the result. */
+    public abstract boolean filterMeta();
+
+    /** Separately add reachable tags. */
+    public abstract boolean filterTagsSeparately();
+
+    public abstract Builder toBuilder();
+
+    public static Builder builder() {
+      return new AutoValue_PermissionBackend_RefFilterOptions.Builder()
+          .setFilterMeta(false)
+          .setFilterTagsSeparately(false);
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder setFilterMeta(boolean val);
+
+      public abstract Builder setFilterTagsSeparately(boolean val);
+
+      public abstract RefFilterOptions build();
+    }
+
+    public static RefFilterOptions defaults() {
+      return builder().build();
+    }
   }
 
   /** PermissionBackend scoped to a user, project and reference. */
diff --git a/java/com/google/gerrit/server/permissions/PermissionCollection.java b/java/com/google/gerrit/server/permissions/PermissionCollection.java
index f3da9c5..e8e6030 100644
--- a/java/com/google/gerrit/server/permissions/PermissionCollection.java
+++ b/java/com/google/gerrit/server/permissions/PermissionCollection.java
@@ -14,18 +14,18 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.common.data.PermissionRule.Action.BLOCK;
 import static com.google.gerrit.server.project.RefPattern.isRE;
+import static java.util.stream.Collectors.mapping;
+import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
-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.common.Nullable;
 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.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -35,13 +35,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * Effective permissions applied to a reference in a project.
@@ -126,78 +126,134 @@
       // LinkedHashMap to maintain input ordering.
       Map<AccessSection, Project.NameKey> sectionToProject = new LinkedHashMap<>();
       boolean perUser = filterRefMatchingSections(matcherList, ref, user, sectionToProject);
-
       List<AccessSection> sections = Lists.newArrayList(sectionToProject.keySet());
 
       // Sort by ref pattern specificity. For equally specific patterns, the sections from the
       // project closer to the current one come first.
       sorter.sort(ref, sections);
 
-      Set<SeenRule> seen = new HashSet<>();
-      Set<String> exclusiveGroupPermissions = new HashSet<>();
+      // For block permissions, we want a different order: first, we want to go from parent to child.
+      List<Map.Entry<AccessSection, Project.NameKey>> accessDescending =
+          Lists.reverse(Lists.newArrayList(sectionToProject.entrySet()));
 
-      HashMap<String, List<PermissionRule>> permissions = new HashMap<>();
-      HashMap<String, List<PermissionRule>> overridden = new HashMap<>();
-      Map<PermissionRule, ProjectRef> ruleProps = Maps.newIdentityHashMap();
-      ListMultimap<Project.NameKey, String> exclusivePermissionsByProject =
-          MultimapBuilder.hashKeys().arrayListValues().build();
-      for (AccessSection section : sections) {
-        Project.NameKey project = sectionToProject.get(section);
-        for (Permission permission : section.getPermissions()) {
-          boolean exclusivePermissionExists =
-              exclusiveGroupPermissions.contains(permission.getName());
-
-          for (PermissionRule rule : permission.getRules()) {
-            SeenRule s = SeenRule.create(section, permission, rule);
-            boolean addRule;
-            if (rule.isBlock()) {
-              addRule = !exclusivePermissionsByProject.containsEntry(project, permission.getName());
-            } else {
-              addRule = seen.add(s) && !rule.isDeny() && !exclusivePermissionExists;
-            }
-
-            HashMap<String, List<PermissionRule>> p = null;
-            if (addRule) {
-              p = permissions;
-            } else if (!rule.isDeny() && !exclusivePermissionExists) {
-              p = overridden;
-            }
-
-            if (p != null) {
-              List<PermissionRule> r = p.get(permission.getName());
-              if (r == null) {
-                r = new ArrayList<>(2);
-                p.put(permission.getName(), r);
-              }
-              r.add(rule);
-              ruleProps.put(rule, ProjectRef.create(project, section.getName()));
-            }
-          }
-
-          if (permission.getExclusiveGroup()) {
-            exclusivePermissionsByProject.put(project, permission.getName());
-            exclusiveGroupPermissions.add(permission.getName());
-          }
-        }
+      Map<Project.NameKey, List<AccessSection>> accessByProject =
+          accessDescending
+              .stream()
+              .collect(
+                  Collectors.groupingBy(
+                      e -> e.getValue(), LinkedHashMap::new, mapping(e -> e.getKey(), toList())));
+      // Within each project, sort by ref specificity.
+      for (List<AccessSection> secs : accessByProject.values()) {
+        sorter.sort(ref, secs);
       }
 
-      return new PermissionCollection(permissions, overridden, ruleProps, perUser);
+      return new PermissionCollection(
+          Lists.newArrayList(accessByProject.values()), sections, perUser);
     }
   }
 
-  private final Map<String, List<PermissionRule>> rules;
-  private final Map<String, List<PermissionRule>> overridden;
-  private final Map<PermissionRule, ProjectRef> ruleProps;
+  /** Returns permissions in the right order for evaluating BLOCK status. */
+  List<List<Permission>> getBlockRules(String perm) {
+    List<List<Permission>> ps = blockPerProjectByPermission.get(perm);
+    if (ps == null) {
+      ps = calculateBlockRules(perm);
+      blockPerProjectByPermission.put(perm, ps);
+    }
+    return ps;
+  }
+
+  /** Returns permissions in the right order for evaluating ALLOW/DENY status. */
+  List<PermissionRule> getAllowRules(String perm) {
+    List<PermissionRule> ps = rulesByPermission.get(perm);
+    if (ps == null) {
+      ps = calculateAllowRules(perm);
+      rulesByPermission.put(perm, ps);
+    }
+    return ps;
+  }
+
+  /** calculates permissions for ALLOW processing. */
+  private List<PermissionRule> calculateAllowRules(String permName) {
+    Set<SeenRule> seen = new HashSet<>();
+
+    List<PermissionRule> r = new ArrayList<>();
+    for (AccessSection s : accessSectionsUpward) {
+      Permission p = s.getPermission(permName);
+      if (p == null) {
+        continue;
+      }
+      for (PermissionRule pr : p.getRules()) {
+        SeenRule sr = SeenRule.create(s, pr);
+        if (seen.contains(sr)) {
+          // We allow only one rule per (ref-pattern, group) tuple. This is used to implement DENY:
+          // If we see a DENY before an ALLOW rule, that causes the ALLOW rule to be skipped here,
+          // negating access.
+          continue;
+        }
+        seen.add(sr);
+
+        if (pr.getAction() == BLOCK) {
+          // Block rules are handled elsewhere.
+          continue;
+        }
+
+        if (pr.getAction() == PermissionRule.Action.DENY) {
+          // DENY rules work by not adding ALLOW rules. Nothing else to do.
+          continue;
+        }
+        r.add(pr);
+      }
+      if (p.getExclusiveGroup()) {
+        // We found an exclusive permission, so no need to further go up the hierarchy.
+        break;
+      }
+    }
+    return r;
+  }
+
+  // Calculates the inputs for determining BLOCK status, grouped by project.
+  private List<List<Permission>> calculateBlockRules(String permName) {
+    List<List<Permission>> result = new ArrayList<>();
+    for (List<AccessSection> secs : this.accessSectionsPerProjectDownward) {
+      List<Permission> perms = new ArrayList<>();
+      boolean blockFound = false;
+      for (AccessSection sec : secs) {
+        Permission p = sec.getPermission(permName);
+        if (p == null) {
+          continue;
+        }
+        for (PermissionRule pr : p.getRules()) {
+          if (blockFound || pr.getAction() == Action.BLOCK) {
+            blockFound = true;
+            break;
+          }
+        }
+
+        perms.add(p);
+      }
+
+      if (blockFound) {
+        result.add(perms);
+      }
+    }
+    return result;
+  }
+
+  private List<List<AccessSection>> accessSectionsPerProjectDownward;
+  private List<AccessSection> accessSectionsUpward;
+
+  private final Map<String, List<PermissionRule>> rulesByPermission;
+  private final Map<String, List<List<Permission>>> blockPerProjectByPermission;
   private final boolean perUser;
 
   private PermissionCollection(
-      Map<String, List<PermissionRule>> rules,
-      Map<String, List<PermissionRule>> overridden,
-      Map<PermissionRule, ProjectRef> ruleProps,
+      List<List<AccessSection>> accessSectionsDownward,
+      List<AccessSection> accessSectionsUpward,
       boolean perUser) {
-    this.rules = rules;
-    this.overridden = overridden;
-    this.ruleProps = ruleProps;
+    this.accessSectionsPerProjectDownward = accessSectionsDownward;
+    this.accessSectionsUpward = accessSectionsUpward;
+    this.rulesByPermission = new HashMap<>();
+    this.blockPerProjectByPermission = new HashMap<>();
     this.perUser = perUser;
   }
 
@@ -209,55 +265,18 @@
     return perUser;
   }
 
-  /**
-   * Obtain all permission rules for a given type of permission.
-   *
-   * @param permissionName type of permission.
-   * @return all rules that apply to this reference, for any group. Never null; the empty list is
-   *     returned when there are no rules for the requested permission name.
-   */
-  public List<PermissionRule> getPermission(String permissionName) {
-    List<PermissionRule> r = rules.get(permissionName);
-    return r != null ? r : Collections.<PermissionRule>emptyList();
-  }
-
-  List<PermissionRule> getOverridden(String permissionName) {
-    return firstNonNull(overridden.get(permissionName), Collections.<PermissionRule>emptyList());
-  }
-
-  ProjectRef getRuleProps(PermissionRule rule) {
-    return ruleProps.get(rule);
-  }
-
-  /**
-   * Obtain all declared permission rules that match the reference.
-   *
-   * @return all rules. The collection will iterate a permission if it was declared in the project
-   *     configuration, either directly or inherited. If the project owner did not use a known
-   *     permission (for example {@link Permission#FORGE_SERVER}, then it will not be represented in
-   *     the result even if {@link #getPermission(String)} returns an empty list for the same
-   *     permission.
-   */
-  public Iterable<Map.Entry<String, List<PermissionRule>>> getDeclaredPermissions() {
-    return rules.entrySet();
-  }
-
   /** (ref, permission, group) tuple. */
   @AutoValue
   abstract static class SeenRule {
     public abstract String refPattern();
 
-    public abstract String permissionName();
-
     @Nullable
     public abstract AccountGroup.UUID group();
 
-    static SeenRule create(
-        AccessSection section, Permission permission, @Nullable PermissionRule rule) {
+    static SeenRule create(AccessSection section, @Nullable PermissionRule rule) {
       AccountGroup.UUID group =
           rule != null && rule.getGroup() != null ? rule.getGroup().getUUID() : null;
-      return new AutoValue_PermissionCollection_SeenRule(
-          section.getName(), permission.getName(), group);
+      return new AutoValue_PermissionCollection_SeenRule(section.getName(), group);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 7441086..00c3948 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -16,7 +16,6 @@
 
 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;
@@ -36,6 +35,7 @@
 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.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionMatcher;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -50,6 +50,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 /** Access control management for a user accessing a project's data. */
 class ProjectControl {
@@ -64,6 +66,7 @@
   private final ProjectState state;
   private final ChangeControl.Factory changeControlFactory;
   private final PermissionCollection.Factory permissionFilter;
+  private final DefaultRefFilter.Factory refFilterFactory;
 
   private List<SectionMatcher> allSections;
   private Map<String, RefControl> refControls;
@@ -76,6 +79,7 @@
       PermissionCollection.Factory permissionFilter,
       ChangeControl.Factory changeControlFactory,
       PermissionBackend permissionBackend,
+      DefaultRefFilter.Factory refFilterFactory,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
     this.changeControlFactory = changeControlFactory;
@@ -83,6 +87,7 @@
     this.receiveGroups = receiveGroups;
     this.permissionFilter = permissionFilter;
     this.permissionBackend = permissionBackend;
+    this.refFilterFactory = refFilterFactory;
     user = who;
     state = ps;
   }
@@ -95,6 +100,7 @@
             permissionFilter,
             changeControlFactory,
             permissionBackend,
+            refFilterFactory,
             who,
             state);
     // Not per-user, and reusing saves lookup time.
@@ -172,6 +178,10 @@
     return match(rule.getGroup().getUUID(), isChangeOwner);
   }
 
+  boolean allRefsAreVisible(Set<String> ignore) {
+    return user.isInternalUser() || canPerformOnAllRefs(Permission.READ, ignore);
+  }
+
   /** Can the user run upload pack? */
   private boolean canRunUploadPack() {
     for (AccountGroup.UUID group : uploadGroups) {
@@ -192,10 +202,6 @@
     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);
@@ -312,6 +318,7 @@
   }
 
   private class ForProjectImpl extends ForProject {
+    private DefaultRefFilter refFilter;
     private String resourcePath;
 
     @Override
@@ -381,6 +388,15 @@
       return ok;
     }
 
+    @Override
+    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+        throws PermissionBackendException {
+      if (refFilter == null) {
+        refFilter = refFilterFactory.create(ProjectControl.this);
+      }
+      return refFilter.filter(refs, repo, opts);
+    }
+
     private boolean can(ProjectPermission perm) throws PermissionBackendException {
       switch (perm) {
         case ACCESS:
@@ -390,9 +406,6 @@
         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:
@@ -406,9 +419,11 @@
         case PUSH_AT_LEAST_ONE_REF:
           return canPushToAtLeastOneRef();
 
+        case READ_CONFIG:
+          return controlForRef(RefNames.REFS_CONFIG).isVisible();
+
         case BAN_COMMIT:
         case READ_REFLOG:
-        case READ_CONFIG:
         case WRITE_CONFIG:
           return isOwner();
       }
diff --git a/java/com/google/gerrit/server/permissions/ProjectPermission.java b/java/com/google/gerrit/server/permissions/ProjectPermission.java
index 6627f76..37f3726 100644
--- a/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -35,14 +35,6 @@
   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
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 0cae939..45ef4c7 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
 import com.google.gerrit.reviewdb.client.Change;
@@ -32,14 +33,9 @@
 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). */
@@ -50,8 +46,7 @@
   /** 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;
+  // The next 4 members are cached canPerform() permissions.
 
   private Boolean owner;
   private Boolean canForgeAuthor;
@@ -62,7 +57,6 @@
     this.projectControl = projectControl;
     this.refName = ref;
     this.relevant = relevant;
-    this.effective = new HashMap<>();
   }
 
   ProjectControl getProjectControl() {
@@ -124,12 +118,12 @@
       // granting of powers beyond submitting to the configuration.
       return projectControl.isOwner();
     }
-    return canPerform(Permission.SUBMIT, isChangeOwner);
+    return canPerform(Permission.SUBMIT, isChangeOwner, false);
   }
 
   /** @return true if this user can force edit topic names. */
   boolean canForceEditTopicName() {
-    return canForcePerform(Permission.EDIT_TOPIC_NAME);
+    return canPerform(Permission.EDIT_TOPIC_NAME, false, true);
   }
 
   /** The range of permitted values associated with a label permission. */
@@ -140,14 +134,14 @@
   /** 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 toRange(permission, isChangeOwner);
     }
     return null;
   }
 
   /** True if the user has this permission. Works only for non labels. */
   boolean canPerform(String permissionName) {
-    return canPerform(permissionName, false);
+    return canPerform(permissionName, false, false);
   }
 
   ForRef asForRef() {
@@ -198,7 +192,7 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return (isOwner() && !isForceBlocked(Permission.PUSH)) || projectControl.isAdmin();
+        return (isOwner() && !isBlocked(Permission.PUSH, false, true)) || projectControl.isAdmin();
     }
   }
 
@@ -211,7 +205,7 @@
       // granting of powers beyond pushing to the configuration.
       return false;
     }
-    return canForcePerform(Permission.PUSH);
+    return canPerform(Permission.PUSH, false, true);
   }
 
   /**
@@ -239,7 +233,10 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return (isOwner() && !isForceBlocked(Permission.PUSH))
+        return
+        // We allow owner to delete refs even if they have no force-push rights. We forbid
+        // it if force push is blocked, though. See commit 40bd5741026863c99bea13eb5384bd27855c5e1b
+        (isOwner() && !isBlocked(Permission.PUSH, false, true))
             || canPushWithForce()
             || canPerform(Permission.DELETE)
             || projectControl.isAdmin();
@@ -267,158 +264,140 @@
     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 static boolean isAllow(PermissionRule pr, boolean withForce) {
+    return pr.getAction() == Action.ALLOW && (pr.getForce() || !withForce);
   }
 
-  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 static boolean isBlock(PermissionRule pr, boolean withForce) {
+    // BLOCK with force specified is a weaker rule than without.
+    return pr.getAction() == Action.BLOCK && (!pr.getForce() || withForce);
   }
 
-  private boolean canPerform(String permissionName, boolean isChangeOwner) {
-    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));
+  private PermissionRange toRange(String permissionName, boolean isChangeOwner) {
+    int blockAllowMin = Integer.MIN_VALUE, blockAllowMax = Integer.MAX_VALUE;
+
+    projectLoop:
+    for (List<Permission> ps : relevant.getBlockRules(permissionName)) {
+      boolean blockFound = false;
+      int projectBlockAllowMin = Integer.MIN_VALUE, projectBlockAllowMax = Integer.MAX_VALUE;
+
+      for (Permission p : ps) {
+        if (p.getExclusiveGroup()) {
+          for (PermissionRule pr : p.getRules()) {
+            if (pr.getAction() == Action.ALLOW && projectControl.match(pr, isChangeOwner)) {
+              // exclusive override, usually for a more specific ref.
+              continue projectLoop;
+            }
+          }
+        }
+
+        for (PermissionRule pr : p.getRules()) {
+          if (pr.getAction() == Action.BLOCK && projectControl.match(pr, isChangeOwner)) {
+            projectBlockAllowMin = pr.getMin() + 1;
+            projectBlockAllowMax = pr.getMax() - 1;
+            blockFound = true;
+          }
+        }
+
+        if (blockFound) {
+          for (PermissionRule pr : p.getRules()) {
+            if (pr.getAction() == Action.ALLOW && projectControl.match(pr, isChangeOwner)) {
+              projectBlockAllowMin = pr.getMin();
+              projectBlockAllowMax = pr.getMax();
+              break;
+            }
+          }
+          break;
+        }
+      }
+
+      blockAllowMin = Math.max(projectBlockAllowMin, blockAllowMin);
+      blockAllowMax = Math.min(projectBlockAllowMax, blockAllowMax);
+    }
+
+    int voteMin = 0, voteMax = 0;
+    for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
+      if (pr.getAction() == PermissionRule.Action.ALLOW
+          && projectControl.match(pr, isChangeOwner)) {
+        // For votes, contrary to normal permissions, we aggregate all applicable rules.
+        voteMin = Math.min(voteMin, pr.getMin());
+        voteMax = Math.max(voteMax, pr.getMax());
       }
     }
-    for (PermissionRule rule : overridden) {
-      blocks.remove(relevant.getRuleProps(rule));
-    }
-    blocks.removeAll(allows);
-    return blocks.isEmpty() && !allows.isEmpty();
+
+    return new PermissionRange(
+        permissionName, Math.max(voteMin, blockAllowMin), Math.min(voteMax, blockAllowMax));
   }
 
-  /** 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));
+  private boolean isBlocked(String permissionName, boolean isChangeOwner, boolean withForce) {
+    // Permissions are ordered by (more general project, more specific ref). Because Permission
+    // does not have back pointers, we can't tell what ref-pattern or project each permission comes
+    // from.
+    List<List<Permission>> downwardPerProject = relevant.getBlockRules(permissionName);
+
+    projectLoop:
+    for (List<Permission> projectRules : downwardPerProject) {
+      boolean overrideFound = false;
+      for (Permission p : projectRules) {
+        // If this is an exclusive ALLOW, then block rules from the same project are ignored.
+        if (p.getExclusiveGroup()) {
+          for (PermissionRule pr : p.getRules()) {
+            if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
+              overrideFound = true;
+              break;
+            }
+          }
+        }
+        if (overrideFound) {
+          // Found an exclusive override, nothing further to do in this project.
+          continue projectLoop;
+        }
+
+        boolean blocked = false;
+        for (PermissionRule pr : p.getRules()) {
+          if (!withForce && pr.getForce()) {
+            // force on block rule only applies to withForce permission.
+            continue;
+          }
+
+          if (isBlock(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
+            blocked = true;
+            break;
+          }
+        }
+
+        if (blocked) {
+          // ALLOW in the same AccessSection (ie. in the same Permission) overrides the BLOCK.
+          for (PermissionRule pr : p.getRules()) {
+            if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
+              blocked = false;
+              break;
+            }
+          }
+        }
+
+        if (blocked) {
+          return true;
+        }
       }
     }
-    for (PermissionRule rule : overridden) {
-      if (rule.getForce()) {
-        blocks.remove(relevant.getRuleProps(rule));
-      }
-    }
-    blocks.removeAll(allows);
-    return blocks.isEmpty() && !allows.isEmpty();
+
+    return false;
   }
 
-  /** 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;
+  /** True if the user has this permission. */
+  private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
+    if (isBlocked(permissionName, isChangeOwner, withForce)) {
+      return false;
     }
 
-    rules = relevant.getPermission(permissionName);
-
-    List<PermissionRule> mine = new ArrayList<>(rules.size());
-    for (PermissionRule rule : rules) {
-      if (projectControl.match(rule, isChangeOwner)) {
-        mine.add(rule);
+    for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
+      if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
+        return true;
       }
     }
 
-    if (mine.isEmpty()) {
-      mine = Collections.emptyList();
-    }
-    effective.put(permissionName, mine);
-    return mine;
+    return false;
   }
 
   private class ForRefImpl extends ForRef {
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 740e8d3..effd51a 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.util.PluginRequestContext;
 import com.google.gerrit.server.util.RequestContext;
@@ -568,7 +569,7 @@
     Class<?> type = key.getTypeLiteral().getRawType();
     if (LifecycleListener.class.isAssignableFrom(type)
         // This is needed for secondary index to work from plugin listeners
-        && !is("com.google.gerrit.server.index.IndexCollection", type)) {
+        && !IndexCollection.class.isAssignableFrom(type)) {
       return false;
     }
     if (StartPluginListener.class.isAssignableFrom(type)) {
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 42389d3..67d8f70 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -16,9 +16,14 @@
 
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.IncludedInResolver;
-import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
+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.Collection;
 import java.util.Map;
@@ -35,22 +40,31 @@
  * 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.
  */
+@Singleton
 public class Reachable {
-  private final VisibleRefFilter.Factory refFilter;
   private static final Logger log = LoggerFactory.getLogger(Reachable.class);
 
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+
   @Inject
-  Reachable(VisibleRefFilter.Factory refFilter) {
-    this.refFilter = refFilter;
+  Reachable(PermissionBackend permissionBackend, Provider<CurrentUser> user) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
   }
 
   /** @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);
+      // TODO(hiesel) Convert interface to Project.NameKey
+      Map<String, Ref> filtered =
+          permissionBackend
+              .user(user)
+              .project(state.getNameKey())
+              .filter(refs, repo, RefFilterOptions.builder().setFilterTagsSeparately(true).build());
       return IncludedInResolver.includedInAny(repo, rw, commit, filtered.values());
-    } catch (IOException e) {
+    } catch (IOException | PermissionBackendException e) {
       log.error(
           String.format(
               "Cannot verify permissions to commit object %s in repository %s",
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index a8434b9..c1da015 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -24,7 +24,6 @@
 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;
@@ -87,7 +86,7 @@
   }
 
   public interface Factory {
-    SubmitRuleEvaluator create(CurrentUser user, ChangeData cd);
+    SubmitRuleEvaluator create(ChangeData cd);
   }
 
   private final AccountCache accountCache;
@@ -99,7 +98,6 @@
   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;
@@ -113,13 +111,11 @@
       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;
   }
 
@@ -229,11 +225,7 @@
     try {
       results =
           evaluateImpl(
-              "locate_submit_rule",
-              "can_submit",
-              "locate_submit_filter",
-              "filter_submit_results",
-              user);
+              "locate_submit_rule", "can_submit", "locate_submit_filter", "filter_submit_results");
     } catch (RuleEvalException e) {
       return ruleError(e.getMessage(), e);
     }
@@ -391,11 +383,7 @@
               "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);
+              "filter_submit_type_results");
     } catch (RuleEvalException e) {
       return typeError(e.getMessage(), e);
     }
@@ -460,10 +448,9 @@
       String userRuleLocatorName,
       String userRuleWrapperName,
       String filterRuleLocatorName,
-      String filterRuleWrapperName,
-      CurrentUser user)
+      String filterRuleWrapperName)
       throws RuleEvalException {
-    PrologEnvironment env = getPrologEnvironment(user);
+    PrologEnvironment env = getPrologEnvironment();
     try {
       Term sr = env.once("gerrit", userRuleLocatorName, new VariableTerm());
       List<Term> results = new ArrayList<>();
@@ -507,7 +494,7 @@
     }
   }
 
-  private PrologEnvironment getPrologEnvironment(CurrentUser user) throws RuleEvalException {
+  private PrologEnvironment getPrologEnvironment() throws RuleEvalException {
     PrologEnvironment env;
     try {
       if (opts.rule() == null) {
@@ -529,9 +516,6 @@
     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;
   }
diff --git a/java/com/google/gerrit/server/project/testing/Util.java b/java/com/google/gerrit/server/project/testing/Util.java
index 2851e81..b80d74c 100644
--- a/java/com/google/gerrit/server/project/testing/Util.java
+++ b/java/com/google/gerrit/server/project/testing/Util.java
@@ -80,6 +80,19 @@
     return grant(project, permissionName, rule, ref);
   }
 
+  public static PermissionRule allowExclusive(
+      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, true);
+  }
+
   public static PermissionRule block(
       ProjectConfig project,
       String permissionName,
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 22df2ce..e643470 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -45,7 +45,7 @@
       preds.add(id(new Account.Id(id)));
     }
     if (canSeeSecondaryEmails) {
-      preds.add(equalsNameIcludingSecondaryEmails(query));
+      preds.add(equalsNameIncludingSecondaryEmails(query));
     } else {
       if (schema.hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
         preds.add(equalsName(query));
@@ -84,7 +84,7 @@
         AccountField.PREFERRED_EMAIL_EXACT, AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT, email);
   }
 
-  public static Predicate<AccountState> equalsNameIcludingSecondaryEmails(String name) {
+  public static Predicate<AccountState> equalsNameIncludingSecondaryEmails(String name) {
     return new AccountPredicate(
         AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
   }
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 055b423..8f6ec8b 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -142,7 +142,7 @@
   public Predicate<AccountState> name(String name)
       throws PermissionBackendException, QueryParseException {
     if (canSeeSecondaryEmails()) {
-      return AccountPredicates.equalsNameIcludingSecondaryEmails(name);
+      return AccountPredicates.equalsNameIncludingSecondaryEmails(name);
     }
 
     if (args.schema().hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 73b212c..e11d51e 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -905,17 +905,13 @@
     return messages;
   }
 
-  public List<SubmitRecord> submitRecords(SubmitRuleOptions options) throws OrmException {
+  public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
     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();
+      records = submitRuleEvaluatorFactory.create(this).setOptions(options).evaluate();
       submitRecords.put(options, records);
     }
     return records;
@@ -930,12 +926,9 @@
     submitRecords.put(options, records);
   }
 
-  public SubmitTypeRecord submitTypeRecord() throws OrmException {
+  public SubmitTypeRecord submitTypeRecord() {
     if (submitTypeRecord == null) {
-      submitTypeRecord =
-          submitRuleEvaluatorFactory
-              .create(userFactory.create(change().getOwner()), this)
-              .getSubmitType();
+      submitTypeRecord = submitRuleEvaluatorFactory.create(this).getSubmitType();
     }
     return submitTypeRecord;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index a28cd9d..1d24377 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -21,6 +21,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Enums;
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
@@ -57,7 +58,6 @@
 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;
@@ -91,7 +91,6 @@
 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. */
@@ -214,7 +213,6 @@
     final Provider<ReviewDb> db;
     final StarredChangesUtil starredChangesUtil;
     final SubmitDryRun submitDryRun;
-    final boolean allowsDrafts;
     final GroupMembers groupMembers;
 
     private final Provider<CurrentUser> self;
@@ -247,7 +245,6 @@
         IndexConfig indexConfig,
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
-        @GerritServerConfig Config cfg,
         NotesMigration notesMigration,
         GroupMembers groupMembers) {
       this(
@@ -276,7 +273,6 @@
           indexConfig,
           starredChangesUtil,
           accountCache,
-          cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true),
           notesMigration,
           groupMembers);
     }
@@ -307,7 +303,6 @@
         IndexConfig indexConfig,
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
-        boolean allowsDrafts,
         NotesMigration notesMigration,
         GroupMembers groupMembers) {
       this.db = db;
@@ -334,7 +329,6 @@
       this.indexConfig = indexConfig;
       this.starredChangesUtil = starredChangesUtil;
       this.accountCache = accountCache;
-      this.allowsDrafts = allowsDrafts;
       this.hasOperands = hasOperands;
       this.notesMigration = notesMigration;
       this.groupMembers = groupMembers;
@@ -367,7 +361,6 @@
           indexConfig,
           starredChangesUtil,
           accountCache,
-          allowsDrafts,
           notesMigration,
           groupMembers);
     }
@@ -526,9 +519,9 @@
     }
 
     // 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]);
+    List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
+    if (names.size() == 2) {
+      ChangeHasOperandFactory op = args.hasOperands.get(names.get(1), names.get(0));
       if (op != null) {
         return op.create(this);
       }
@@ -729,12 +722,12 @@
     // 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'
+    List<String> splitReviewer = Lists.newArrayList(Splitter.on(',').limit(2).split(name));
+    name = splitReviewer.get(0); // remove all but the vote piece, e.g.'CodeReview=1'
 
-    if (splitReviewer.length == 2) {
+    if (splitReviewer.size() == 2) {
       // process the user/group piece
-      PredicateArgs lblArgs = new PredicateArgs(splitReviewer[1]);
+      PredicateArgs lblArgs = new PredicateArgs(splitReviewer.get(1));
 
       for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) {
         if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 185517a..64c6208 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -24,7 +24,6 @@
 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;
@@ -82,7 +81,6 @@
   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;
@@ -107,7 +105,6 @@
       ChangeQueryProcessor queryProcessor,
       EventFactory eventFactory,
       TrackingFooters trackingFooters,
-      CurrentUser user,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
     this.db = db;
     this.repoManager = repoManager;
@@ -115,7 +112,6 @@
     this.queryProcessor = queryProcessor;
     this.eventFactory = eventFactory;
     this.trackingFooters = trackingFooters;
-    this.user = user;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
   }
 
@@ -254,7 +250,7 @@
 
     if (includeSubmitRecords) {
       eventFactory.addSubmitRecords(
-          c, submitRuleEvaluatorFactory.create(user, d).setAllowClosed(true).evaluate());
+          c, submitRuleEvaluatorFactory.create(d).setAllowClosed(true).evaluate());
     }
 
     if (includeCommitMessage) {
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
index ad7a57d..ad7917e 100644
--- a/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.index.query.QueryParseException;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -45,18 +46,16 @@
     positional = new ArrayList<>();
     keyValue = new HashMap<>();
 
-    String[] splitArgs = args.split(",");
+    for (String arg : Splitter.on(',').split(args)) {
+      List<String> splitKeyValue = Splitter.on('=').splitToList(arg);
 
-    for (String arg : splitArgs) {
-      String[] splitKeyValue = arg.split("=");
-
-      if (splitKeyValue.length == 1) {
-        positional.add(splitKeyValue[0]);
-      } else if (splitKeyValue.length == 2) {
-        if (!keyValue.containsKey(splitKeyValue[0])) {
-          keyValue.put(splitKeyValue[0], splitKeyValue[1]);
+      if (splitKeyValue.size() == 1) {
+        positional.add(splitKeyValue.get(0));
+      } else if (splitKeyValue.size() == 2) {
+        if (!keyValue.containsKey(splitKeyValue.get(0))) {
+          keyValue.put(splitKeyValue.get(0), splitKeyValue.get(1));
         } else {
-          throw new QueryParseException("Duplicate key " + splitKeyValue[0]);
+          throw new QueryParseException("Duplicate key " + splitKeyValue.get(0));
         }
       } else {
         throw new QueryParseException("invalid arg " + arg);
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index 057cc44..296dc17 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
@@ -32,9 +31,6 @@
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -56,18 +52,12 @@
       new QueryBuilder.Definition<>(GroupQueryBuilder.class);
 
   public static class Arguments {
-    final GroupIndex groupIndex;
     final GroupCache groupCache;
     final GroupBackend groupBackend;
     final AccountResolver accountResolver;
 
     @Inject
-    Arguments(
-        GroupIndexCollection groupIndexCollection,
-        GroupCache groupCache,
-        GroupBackend groupBackend,
-        AccountResolver accountResolver) {
-      this.groupIndex = groupIndexCollection.getSearchIndex();
+    Arguments(GroupCache groupCache, GroupBackend groupBackend, AccountResolver accountResolver) {
       this.groupCache = groupCache;
       this.groupBackend = groupBackend;
       this.accountResolver = accountResolver;
@@ -144,10 +134,6 @@
   @Operator
   public Predicate<InternalGroup> member(String query)
       throws QueryParseException, OrmException, ConfigInvalidException, IOException {
-    if (isFieldAbsentFromIndex(GroupField.MEMBER)) {
-      throw getExceptionForUnsupportedOperator("member");
-    }
-
     Set<Account.Id> accounts = parseAccount(query);
     List<Predicate<InternalGroup>> predicates =
         accounts.stream().map(GroupPredicates::member).collect(toImmutableList());
@@ -156,10 +142,6 @@
 
   @Operator
   public Predicate<InternalGroup> subgroup(String query) throws QueryParseException {
-    if (isFieldAbsentFromIndex(GroupField.SUBGROUP)) {
-      throw getExceptionForUnsupportedOperator("subgroup");
-    }
-
     AccountGroup.UUID groupUuid = parseGroup(query);
     return GroupPredicates.subgroup(groupUuid);
   }
@@ -173,15 +155,6 @@
     return new LimitPredicate<>(FIELD_LIMIT, limit);
   }
 
-  private boolean isFieldAbsentFromIndex(FieldDef<InternalGroup, ?> field) {
-    return !args.groupIndex.getSchema().hasField(field);
-  }
-
-  private static QueryParseException getExceptionForUnsupportedOperator(String operatorName) {
-    return new QueryParseException(
-        String.format("'%s' operator is not supported by group index version", operatorName));
-  }
-
   private Set<Account.Id> parseAccount(String nameOrEmail)
       throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     Set<Account.Id> foundAccounts = args.accountResolver.findAll(nameOrEmail);
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index 29c331f..31bf93f 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -119,7 +119,7 @@
       throw new BadRequestException("username must match URL");
     }
 
-    if (!username.matches(ExternalId.USER_NAME_PATTERN_REGEX)) {
+    if (!ExternalId.isValidUsername(username)) {
       throw new BadRequestException(
           "Username '" + username + "' must contain only letters, numbers, _, - or .");
     }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
index 3d103ec..61ba8cc 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
@@ -41,6 +41,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -81,7 +82,7 @@
             .collect(toMap(i -> i.key(), i -> i));
 
     List<ExternalId> toDelete = new ArrayList<>();
-    ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
+    Optional<ExternalId.Key> last = resource.getUser().getLastLoginExternalIdKey();
     for (String externalIdStr : extIds) {
       ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
 
@@ -91,7 +92,7 @@
       }
 
       if ((!id.isScheme(SCHEME_USERNAME))
-          && ((last == null) || (!last.get().equals(id.key().get())))) {
+          && (!last.isPresent() || (!last.get().equals(id.key())))) {
         toDelete.add(id);
       } else {
         throw new ResourceConflictException(
diff --git a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
index 8e456a2..284c7f9 100644
--- a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
@@ -37,6 +37,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 
 @Singleton
 public class GetExternalIds implements RestReadView<AccountResource> {
@@ -78,8 +79,8 @@
       // 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));
+        Optional<ExternalId.Key> last = resource.getUser().getLastLoginExternalIdKey();
+        info.canDelete = toBoolean(!last.isPresent() || !last.get().get().equals(info.identity));
       }
       result.add(info);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 9bbd56f..499cbb6 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -16,6 +16,7 @@
 
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
+import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
@@ -240,14 +241,23 @@
       AccountState accountState = me.state();
       GeneralPreferencesInfo info = accountState.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);
+      // Add a Change-Id line if there isn't already one
+      String commitMessage = input.subject;
+      if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
+        ObjectId treeId = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree();
+        ObjectId id = ChangeIdUtil.computeChangeId(treeId, mergeTip, author, author, commitMessage);
+        commitMessage = ChangeIdUtil.insertId(commitMessage, id);
+      }
+
       if (Boolean.TRUE.equals(info.signedOffBy)) {
-        commitMessage +=
-            String.format(
-                "%s%s",
-                SIGNED_OFF_BY_TAG, accountState.getAccount().getNameEmail(anonymousCowardName));
+        commitMessage =
+            Joiner.on("\n")
+                .join(
+                    commitMessage.trim(),
+                    String.format(
+                        "%s%s",
+                        SIGNED_OFF_BY_TAG,
+                        accountState.getAccount().getNameEmail(anonymousCowardName)));
       }
 
       RevCommit c;
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
index 1853853..9658fb4 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeOp.java
@@ -38,15 +38,10 @@
 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;
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 7a2f424..47d76b2 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -26,7 +26,6 @@
 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;
@@ -113,7 +112,7 @@
     }
 
     ChangeData cd = changeDataFactory.create(db.get(), resource.getNotes());
-    result.submitType = getSubmitType(resource.getUser(), cd, ps);
+    result.submitType = getSubmitType(cd, ps);
 
     try (Repository git = gitManager.openRepository(change.getProject())) {
       ObjectId commit = toId(ps);
@@ -145,10 +144,9 @@
     return result;
   }
 
-  private SubmitType getSubmitType(CurrentUser user, ChangeData cd, PatchSet patchSet)
-      throws OrmException {
+  private SubmitType getSubmitType(ChangeData cd, PatchSet patchSet) throws OrmException {
     SubmitTypeRecord rec =
-        submitRuleEvaluatorFactory.create(user, cd).setPatchSet(patchSet).getSubmitType();
+        submitRuleEvaluatorFactory.create(cd).setPatchSet(patchSet).getSubmitType();
     if (rec.status != SubmitTypeRecord.Status.OK) {
       throw new OrmException("Submit type rule failed: " + rec);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 8190735..8e78ec0 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -135,7 +135,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.Optional;
 import java.util.OptionalInt;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -334,11 +333,9 @@
         // 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.
-        Optional<PostReviewers.Addition> selfAddition =
+        PostReviewers.Addition selfAddition =
             postReviewers.ccCurrentUser(revision.getUser(), revision);
-        if (selfAddition.isPresent()) {
-          bu.addOp(revision.getChange().getId(), selfAddition.get().op);
-        }
+        bu.addOp(revision.getChange().getId(), selfAddition.op);
       }
 
       // Add WorkInProgressOp if requested.
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 102bf21..4ff3862 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -80,7 +80,6 @@
 import java.text.MessageFormat;
 import java.util.Collection;
 import java.util.HashSet;
-import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -209,18 +208,15 @@
     return addByEmail(reviewer, rsrc, state, notify, accountsToNotify);
   }
 
-  Optional<Addition> ccCurrentUser(CurrentUser user, RevisionResource revision) {
-    return user.getUserName()
-        .map(
-            u ->
-                new Addition(
-                    u,
-                    revision.getChangeResource(),
-                    ImmutableSet.of(user.getAccountId()),
-                    null,
-                    CC,
-                    NotifyHandling.NONE,
-                    ImmutableListMultimap.of()));
+  Addition ccCurrentUser(CurrentUser user, RevisionResource revision) {
+    return new Addition(
+        user.getUserName().orElse(null),
+        revision.getChangeResource(),
+        ImmutableSet.of(user.getAccountId()),
+        null,
+        CC,
+        NotifyHandling.NONE,
+        ImmutableListMultimap.of());
   }
 
   @Nullable
@@ -451,7 +447,7 @@
         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));
+          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), perm.user(u), cd));
         }
         accountLoaderFactory.create(true).fill(result.ccs);
         for (Address a : reviewersByEmail) {
@@ -464,7 +460,6 @@
           IdentifiedUser u = identifiedUserFactory.create(psa.getAccountId());
           result.reviewers.add(
               json.format(
-                  caller,
                   new ReviewerInfo(psa.getAccountId().get()),
                   perm.user(u),
                   cd,
diff --git a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
index a78f9b5..303401c 100644
--- a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
@@ -275,5 +275,15 @@
     public int hashCode() {
       return Objects.hash(patchSet().getId(), commit());
     }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (!(obj instanceof PatchSetData)) {
+        return false;
+      }
+      PatchSetData o = (PatchSetData) obj;
+      return Objects.equals(patchSet().getId(), o.patchSet().getId())
+          && Objects.equals(commit(), o.commit());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
index 228eb47..ef5b432 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerJson.java
@@ -27,7 +27,6 @@
 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;
@@ -79,7 +78,6 @@
       }
       ReviewerInfo info =
           format(
-              rsrc.getChangeResource().getUser(),
               new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
               permissionBackend.user(rsrc.getReviewerUser()).database(db).change(cd),
               cd);
@@ -95,12 +93,10 @@
     return format(ImmutableList.<ReviewerResource>of(rsrc));
   }
 
-  public ReviewerInfo format(
-      CurrentUser user, ReviewerInfo out, PermissionBackend.ForChange perm, ChangeData cd)
+  public ReviewerInfo format(ReviewerInfo out, PermissionBackend.ForChange perm, ChangeData cd)
       throws OrmException, PermissionBackendException {
     PatchSet.Id psId = cd.change().currentPatchSetId();
     return format(
-        user,
         out,
         perm,
         cd,
@@ -109,7 +105,6 @@
   }
 
   public ReviewerInfo format(
-      CurrentUser user,
       ReviewerInfo out,
       PermissionBackend.ForChange perm,
       ChangeData cd,
@@ -129,7 +124,7 @@
     // do not exist in the DB.
     PatchSet ps = cd.currentPatchSet();
     if (ps != null) {
-      for (SubmitRecord rec : submitRuleEvaluatorFactory.create(user, cd).evaluate()) {
+      for (SubmitRecord rec : submitRuleEvaluatorFactory.create(cd).evaluate()) {
         if (rec.labels == null) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index a27d376..5f6b088 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -130,24 +130,19 @@
                   .get()
                   .suggestReviewers(
                       projectState.getNameKey(),
-                      changeNotes.getChangeId(),
+                      changeNotes != null ? changeNotes.getChangeId() : null,
                       query,
                       reviewerScores.keySet()));
-      String pluginWeight =
-          config.getString(
-              "addReviewer", plugin.getPluginName() + "-" + plugin.getExportName(), "weight");
+      String key = plugin.getPluginName() + "-" + plugin.getExportName();
+      String pluginWeight = config.getString("addReviewer", key, "weight");
       if (Strings.isNullOrEmpty(pluginWeight)) {
         pluginWeight = "1";
       }
+      log.debug("weight for {}: {}", key, pluginWeight);
       try {
         weights.add(Double.parseDouble(pluginWeight));
       } catch (NumberFormatException e) {
-        log.error(
-            "Exception while parsing weight for "
-                + plugin.getPluginName()
-                + "-"
-                + plugin.getExportName(),
-            e);
+        log.error("Exception while parsing weight for {}", key, e);
         weights.add(1d);
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 7a2a148..38ed756 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -65,6 +65,8 @@
 import java.util.Objects;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class ReviewersUtil {
   @Singleton
@@ -110,6 +112,8 @@
     }
   }
 
+  private static final Logger log = LoggerFactory.getLogger(ReviewersUtil.class);
+
   // 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;
@@ -163,20 +167,29 @@
       VisibilityControl visibilityControl,
       boolean excludeGroups)
       throws IOException, OrmException, ConfigInvalidException, PermissionBackendException {
+    CurrentUser currentUser = self.get();
+    log.debug(
+        "Suggesting reviewers for change {} to user {}.",
+        changeNotes.getChangeId().get(),
+        currentUser.getLoggableName());
     String query = suggestReviewers.getQuery();
+    log.debug("Query: {}", query);
     int limit = suggestReviewers.getLimit();
 
     if (!suggestReviewers.getSuggestAccounts()) {
+      log.debug("Reviewer suggestion is disabled.");
       return Collections.emptyList();
     }
 
     List<Account.Id> candidateList = new ArrayList<>();
     if (!Strings.isNullOrEmpty(query)) {
       candidateList = suggestAccounts(suggestReviewers);
+      log.debug("Candidate list: {}", candidateList);
     }
 
     List<Account.Id> sortedRecommendations =
         recommendAccounts(changeNotes, suggestReviewers, projectState, candidateList);
+    log.debug("Sorted recommendations: {}", sortedRecommendations);
 
     // Filter accounts by visibility and enforce limit
     List<Account.Id> filteredRecommendations = new ArrayList<>();
@@ -192,20 +205,40 @@
         }
       }
     }
+    log.debug("Filtered recommendations: {}", filteredRecommendations);
 
-    List<SuggestedReviewerInfo> suggestedReviewer = loadAccounts(filteredRecommendations);
-    if (!excludeGroups && suggestedReviewer.size() < limit && !Strings.isNullOrEmpty(query)) {
+    List<SuggestedReviewerInfo> suggestedReviewers = loadAccounts(filteredRecommendations);
+    if (!excludeGroups && suggestedReviewers.size() < limit && !Strings.isNullOrEmpty(query)) {
       // Add groups at the end as individual accounts are usually more
       // important.
-      suggestedReviewer.addAll(
+      suggestedReviewers.addAll(
           suggestAccountGroups(
-              suggestReviewers, projectState, visibilityControl, limit - suggestedReviewer.size()));
+              suggestReviewers,
+              projectState,
+              visibilityControl,
+              limit - suggestedReviewers.size()));
     }
 
-    if (suggestedReviewer.size() <= limit) {
-      return suggestedReviewer;
+    if (suggestedReviewers.size() > limit) {
+      suggestedReviewers = suggestedReviewers.subList(0, limit);
+      log.debug("Limited suggested reviewers to {} accounts.", limit);
     }
-    return suggestedReviewer.subList(0, limit);
+    log.debug(
+        "Suggested reviewers: {}",
+        suggestedReviewers
+            .stream()
+            .map(
+                r -> {
+                  if (r.account != null) {
+                    return "a/" + r.account._accountId;
+                  } else if (r.group != null) {
+                    return "g/" + r.group.id;
+                  } else {
+                    return "";
+                  }
+                })
+            .collect(toList()));
+    return suggestedReviewers;
   }
 
   private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers) throws OrmException {
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
index 4dc5b06..4033d10 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -19,14 +19,16 @@
 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.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
-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.ProjectCache;
 import com.google.gerrit.server.restapi.change.ReviewersUtil.VisibilityControl;
 import com.google.gwtorm.server.OrmException;
@@ -48,6 +50,7 @@
   )
   boolean excludeGroups;
 
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
   private final ProjectCache projectCache;
 
@@ -56,11 +59,13 @@
       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;
   }
@@ -81,11 +86,15 @@
   }
 
   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);
+    // Use the destination reference, not the change, as private changes deny anyone who is not
+    // already a reviewer.
+    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/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index efb0f4d..eb356ed 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -71,8 +71,7 @@
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
     SubmitRuleEvaluator evaluator =
-        submitRuleEvaluatorFactory.create(
-            rsrc.getUser(), changeDataFactory.create(db.get(), rsrc.getNotes()));
+        submitRuleEvaluatorFactory.create(changeDataFactory.create(db.get(), rsrc.getNotes()));
 
     List<SubmitRecord> records =
         evaluator
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
index 2782a66..d8d5d0a 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -65,8 +65,7 @@
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
     SubmitRuleEvaluator evaluator =
-        submitRuleEvaluatorFactory.create(
-            rsrc.getUser(), changeDataFactory.create(db.get(), rsrc.getNotes()));
+        submitRuleEvaluatorFactory.create(changeDataFactory.create(db.get(), rsrc.getNotes()));
 
     SubmitTypeRecord rec =
         evaluator
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index f31277d..3fb4ed1 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -231,7 +231,6 @@
   private ChangeConfigInfo getChangeInfo(Config cfg) {
     ChangeConfigInfo info = new ChangeConfigInfo();
     info.allowBlame = toBoolean(cfg.getBoolean("change", "allowBlame", true));
-    info.allowDrafts = toBoolean(cfg.getBoolean("change", "allowDrafts", true));
     boolean hasAssigneeInIndex =
         indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
     info.showAssigneeInChangesTable =
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index 224a014..f65b29f 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -190,7 +190,7 @@
   }
 
   private Optional<Account> createAccountByLdap(String user) throws IOException {
-    if (!user.matches(ExternalId.USER_NAME_PATTERN_REGEX)) {
+    if (!ExternalId.isValidUsername(user)) {
       return Optional.empty();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index d0eca26..a0c88f2 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -139,12 +139,12 @@
       throw new BadRequestException("name must match URL");
     }
 
-    AccountGroup.Id ownerId = owner(input);
+    AccountGroup.UUID ownerUuid = 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;
+    args.ownerGroupUuid = ownerUuid;
     if (input.members != null && !input.members.isEmpty()) {
       List<Account.Id> members = new ArrayList<>();
       for (String nameOrEmailOrId : input.members) {
@@ -158,7 +158,7 @@
       args.initialMembers = members;
     } else {
       args.initialMembers =
-          ownerId == null
+          ownerUuid == null
               ? Collections.singleton(self.get().getAccountId())
               : Collections.<Account.Id>emptySet();
     }
@@ -174,10 +174,10 @@
     return json.format(new InternalGroupDescription(createGroup(args)));
   }
 
-  private AccountGroup.Id owner(GroupInput input) throws UnprocessableEntityException {
+  private AccountGroup.UUID owner(GroupInput input) throws UnprocessableEntityException {
     if (input.ownerId != null) {
       GroupDescription.Internal d = groups.parseInternal(Url.decode(input.ownerId));
-      return d.getId();
+      return d.getGroupUUID();
     }
     return null;
   }
@@ -212,8 +212,8 @@
             .build();
     InternalGroupUpdate.Builder groupUpdateBuilder =
         InternalGroupUpdate.builder().setVisibleToAll(createGroupArgs.visibleToAll);
-    if (createGroupArgs.ownerGroupId != null) {
-      Optional<InternalGroup> ownerGroup = groupCache.get(createGroupArgs.ownerGroupId);
+    if (createGroupArgs.ownerGroupUuid != null) {
+      Optional<InternalGroup> ownerGroup = groupCache.get(createGroupArgs.ownerGroupUuid);
       ownerGroup.map(InternalGroup::getGroupUUID).ifPresent(groupUpdateBuilder::setOwnerGroupUUID);
     }
     if (createGroupArgs.groupDescription != null) {
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index b7a8d55..38b22a9 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -17,7 +17,6 @@
 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;
@@ -34,10 +33,14 @@
 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.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.inject.Inject;
 import com.google.inject.Provider;
+import java.util.Optional;
 
 public class GroupsCollection
     implements RestCollection<TopLevelResource, GroupResource>,
@@ -49,6 +52,7 @@
   private final CreateGroup.Factory createGroup;
   private final GroupControl.Factory groupControlFactory;
   private final GroupBackend groupBackend;
+  private final GroupCache groupCache;
   private final Provider<CurrentUser> self;
 
   private boolean hasQuery2;
@@ -61,6 +65,7 @@
       CreateGroup.Factory createGroup,
       GroupControl.Factory groupControlFactory,
       GroupBackend groupBackend,
+      GroupCache groupCache,
       Provider<CurrentUser> self) {
     this.views = views;
     this.list = list;
@@ -68,6 +73,7 @@
     this.createGroup = createGroup;
     this.groupControlFactory = groupControlFactory;
     this.groupBackend = groupBackend;
+    this.groupCache = groupCache;
     this.self = self;
   }
 
@@ -167,12 +173,15 @@
       }
     }
 
-    // Might be a legacy AccountGroup.Id.
+    // Might be a numeric AccountGroup.Id. -> Internal group.
     if (id.matches("^[1-9][0-9]*$")) {
       try {
-        AccountGroup.Id legacyId = AccountGroup.Id.parse(id);
-        return groupControlFactory.controlFor(legacyId).getGroup();
-      } catch (IllegalArgumentException | NoSuchGroupException e) {
+        AccountGroup.Id groupId = AccountGroup.Id.parse(id);
+        Optional<InternalGroup> group = groupCache.get(groupId);
+        if (group.isPresent()) {
+          return new InternalGroupDescription(group.get());
+        }
+      } catch (IllegalArgumentException e) {
         // Ignored
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/group/Index.java b/java/com/google/gerrit/server/restapi/group/Index.java
index 22003c0..1267f7a 100644
--- a/java/com/google/gerrit/server/restapi/group/Index.java
+++ b/java/com/google/gerrit/server/restapi/group/Index.java
@@ -20,22 +20,21 @@
 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.gerrit.server.index.group.GroupIndexer;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 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;
+  private final Provider<GroupIndexer> indexer;
 
   @Inject
-  Index(GroupCache groupCache) {
-    this.groupCache = groupCache;
+  Index(Provider<GroupIndexer> indexer) {
+    this.indexer = indexer;
   }
 
   @Override
@@ -51,11 +50,7 @@
           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());
-    }
+    indexer.get().index(groupUuid);
     return Response.none();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index 9e431df..21d6013 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -24,11 +24,11 @@
 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.GroupDescription;
 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.WebLinkInfoCommon;
-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;
@@ -45,7 +45,6 @@
 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;
@@ -58,8 +57,6 @@
 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;
@@ -77,9 +74,6 @@
 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,
@@ -95,42 +89,36 @@
 
   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;
   private final WebLinks webLinks;
 
   @Inject
   public GetAccess(
       Provider<CurrentUser> self,
       PermissionBackend permissionBackend,
-      GroupControl.Factory groupControlFactory,
       AllProjectsName allProjectsName,
       ProjectCache projectCache,
       MetaDataUpdate.Server metaDataUpdateFactory,
       ProjectJson projectJson,
       GroupBackend groupBackend,
-      GroupJson groupJson,
       WebLinks webLinks) {
     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;
     this.webLinks = webLinks;
   }
 
   public ProjectAccessInfo apply(Project.NameKey nameKey)
       throws ResourceNotFoundException, ResourceConflictException, IOException,
-          PermissionBackendException, OrmException {
+          PermissionBackendException {
     ProjectState state = projectCache.checkedGet(nameKey);
     if (state == null) {
       throw new ResourceNotFoundException(nameKey.get());
@@ -141,7 +129,7 @@
   @Override
   public ProjectAccessInfo apply(ProjectResource rsrc)
       throws ResourceNotFoundException, ResourceConflictException, IOException,
-          PermissionBackendException, OrmException {
+          PermissionBackendException {
     // 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.
@@ -189,7 +177,7 @@
 
     info.local = new HashMap<>();
     info.ownerOf = new HashSet<>();
-    Map<AccountGroup.UUID, GroupInfo> visibleGroups = new HashMap<>();
+    Map<AccountGroup.UUID, GroupInfo> groups = new HashMap<>();
     boolean canReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
     boolean canWriteConfig = check(perm, ProjectPermission.WRITE_CONFIG);
 
@@ -204,20 +192,20 @@
       String name = section.getName();
       if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
         if (canWriteConfig) {
-          info.local.put(name, createAccessSection(visibleGroups, section));
+          info.local.put(name, createAccessSection(groups, section));
           info.ownerOf.add(name);
 
         } else if (canReadConfig) {
-          info.local.put(section.getName(), createAccessSection(visibleGroups, section));
+          info.local.put(section.getName(), createAccessSection(groups, section));
         }
 
       } else if (RefConfigSection.isValid(name)) {
         if (check(perm, name, WRITE_CONFIG)) {
-          info.local.put(name, createAccessSection(visibleGroups, section));
+          info.local.put(name, createAccessSection(groups, section));
           info.ownerOf.add(name);
 
         } else if (canReadConfig) {
-          info.local.put(name, createAccessSection(visibleGroups, section));
+          info.local.put(name, createAccessSection(groups, section));
 
         } else if (check(perm, name, READ)) {
           // Filter the section to only add rules describing groups that
@@ -235,18 +223,15 @@
                 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);
+              loadGroup(groups, groupId);
+              if (dstPerm == null) {
+                if (dst == null) {
+                  dst = new AccessSection(name);
+                  info.local.put(name, createAccessSection(groups, dst));
                 }
-                dstPerm.add(srcRule);
+                dstPerm = dst.getPermission(srcPerm.getName(), true);
               }
+              dstPerm.add(srcRule);
             }
           }
         }
@@ -285,34 +270,31 @@
     info.configVisible = canReadConfig || canWriteConfig;
 
     info.groups =
-        visibleGroups
+        groups
             .entrySet()
             .stream()
-            .filter(e -> e.getValue() != INVISIBLE_SENTINEL)
+            .filter(e -> e.getValue() != null)
             .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;
+  private void loadGroup(Map<AccountGroup.UUID, GroupInfo> groups, AccountGroup.UUID id) {
+    if (!groups.containsKey(id)) {
+      GroupDescription.Basic basic = groupBackend.get(id);
+      GroupInfo group;
+      if (basic != null) {
+        group = new GroupInfo();
+        // The UI only needs name + URL, so don't populate other fields to avoid leaking data
+        // about groups invisible to the user.
+        group.name = basic.getName();
+        group.url = basic.getUrl();
+      } else {
+        LOG.warn("no such group: " + id);
+        group = null;
       }
-      visibleGroups.put(id, group);
+      groups.put(id, group);
     }
-
-    return group;
   }
 
   private static boolean check(PermissionBackend.ForProject ctx, String ref, RefPermission perm)
@@ -336,7 +318,7 @@
   }
 
   private AccessSectionInfo createAccessSection(
-      Map<AccountGroup.UUID, GroupInfo> groups, AccessSection section) throws OrmException {
+      Map<AccountGroup.UUID, GroupInfo> groups, AccessSection section) {
     AccessSectionInfo accessSectionInfo = new AccessSectionInfo();
     accessSectionInfo.permissions = new HashMap<>();
     for (Permission p : section.getPermissions()) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index a038bfe..b0148c1 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -27,8 +27,9 @@
 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.PermissionBackend.RefFilterOptions;
+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.ProjectState;
@@ -58,7 +59,6 @@
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
-  private final VisibleRefFilter.Factory refFilterFactory;
   private final WebLinks links;
 
   @Option(
@@ -111,12 +111,10 @@
       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;
   }
 
@@ -130,7 +128,7 @@
 
   @Override
   public List<TagInfo> apply(ProjectResource resource)
-      throws IOException, ResourceNotFoundException, RestApiException {
+      throws IOException, ResourceNotFoundException, RestApiException, PermissionBackendException {
     resource.getProjectState().checkStatePermitsRead();
 
     List<TagInfo> tags = new ArrayList<>();
@@ -139,8 +137,7 @@
     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));
+          visibleTags(resource.getNameKey(), repo, repo.getRefDatabase().getRefs(Constants.R_TAGS));
       for (Ref ref : all.values()) {
         tags.add(
             createTagInfo(perm.ref(ref.getName()), ref, rw, resource.getProjectState(), links));
@@ -165,7 +162,7 @@
   }
 
   public TagInfo get(ProjectResource resource, IdString id)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
     try (Repository repo = getRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
       String tagName = id.get();
@@ -174,7 +171,7 @@
       }
       Ref ref = repo.getRefDatabase().exactRef(tagName);
       if (ref != null
-          && !visibleTags(resource.getProjectState(), repo, ImmutableMap.of(ref.getName(), ref))
+          && !visibleTags(resource.getNameKey(), repo, ImmutableMap.of(ref.getName(), ref))
               .isEmpty()) {
         return createTagInfo(
             permissionBackend
@@ -235,7 +232,15 @@
     }
   }
 
-  private Map<String, Ref> visibleTags(ProjectState state, Repository repo, Map<String, Ref> tags) {
-    return refFilterFactory.create(state, repo).setShowMetadata(false).filter(tags, true);
+  private Map<String, Ref> visibleTags(
+      Project.NameKey project, Repository repo, Map<String, Ref> tags)
+      throws PermissionBackendException {
+    return permissionBackend
+        .user(user)
+        .project(project)
+        .filter(
+            tags,
+            repo,
+            RefFilterOptions.builder().setFilterMeta(true).setFilterTagsSeparately(true).build());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/TagsCollection.java b/java/com/google/gerrit/server/restapi/project/TagsCollection.java
index c503094..fdace77 100644
--- a/java/com/google/gerrit/server/restapi/project/TagsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/TagsCollection.java
@@ -21,6 +21,7 @@
 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.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.TagResource;
 import com.google.inject.Inject;
@@ -52,7 +53,7 @@
 
   @Override
   public TagResource parse(ProjectResource parent, IdString id)
-      throws RestApiException, IOException {
+      throws RestApiException, IOException, PermissionBackendException {
     parent.getProjectState().checkStatePermitsRead();
     return new TagResource(parent.getProjectState(), parent.getUser(), list.get().get(parent, id));
   }
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 287845d..9fc3557 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -24,7 +24,6 @@
 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;
@@ -54,7 +53,6 @@
   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 {
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index c274e56..43f39b2 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -181,7 +181,7 @@
     }
   }
 
-  private static void doCreateTable(Statement stmt) throws SQLException {
+  protected void doCreateTable(Statement stmt) throws SQLException {
     stmt.executeUpdate(
         "CREATE TABLE IF NOT EXISTS account_patch_reviews ("
             + "account_id INTEGER DEFAULT 0 NOT NULL, "
diff --git a/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
index af84465..d648ed0 100644
--- a/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/MysqlAccountPatchReviewStore.java
@@ -22,6 +22,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.SQLException;
+import java.sql.Statement;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -50,4 +51,17 @@
         return new OrmException(op + " failure on ACCOUNT_PATCH_REVIEWS", err);
     }
   }
+
+  @Override
+  protected void doCreateTable(Statement stmt) throws SQLException {
+    stmt.executeUpdate(
+        "CREATE TABLE IF NOT EXISTS account_patch_reviews ("
+            + "account_id INTEGER DEFAULT 0 NOT NULL, "
+            + "change_id INTEGER DEFAULT 0 NOT NULL, "
+            + "patch_set_id INTEGER DEFAULT 0 NOT NULL, "
+            + "file_name VARCHAR(255) DEFAULT '' NOT NULL, "
+            + "CONSTRAINT primary_key_account_patch_reviews "
+            + "PRIMARY KEY (change_id, patch_set_id, account_id, file_name)"
+            + ")");
+  }
 }
diff --git a/java/com/google/gerrit/server/schema/Schema_159.java b/java/com/google/gerrit/server/schema/Schema_159.java
index 3bb0e98..f97453e 100644
--- a/java/com/google/gerrit/server/schema/Schema_159.java
+++ b/java/com/google/gerrit/server/schema/Schema_159.java
@@ -35,26 +35,32 @@
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
-    DraftWorkflowMigrationStrategy strategy = DraftWorkflowMigrationStrategy.PRIVATE;
-    if (ui.yesno(
-        false, "Migrate draft changes to work-in-progress changes (default is private)?")) {
-      strategy = DraftWorkflowMigrationStrategy.WORK_IN_PROGRESS;
+    DraftWorkflowMigrationStrategy strategy = DraftWorkflowMigrationStrategy.WORK_IN_PROGRESS;
+    if (ui.yesno(false, "Migrate draft changes to private changes (default is work-in-progress)")) {
+      strategy = DraftWorkflowMigrationStrategy.PRIVATE;
     }
     ui.message(
         String.format("Replace draft changes with %s changes ...", strategy.name().toLowerCase()));
     try (StatementExecutor e = newExecutor(db)) {
       String column =
           strategy == DraftWorkflowMigrationStrategy.PRIVATE ? "is_private" : "work_in_progress";
-      // Mark changes private/wip if changes have status draft or
-      // if they have any draft patch sets.
+      // Mark changes private/WIP and NEW if either:
+      // * they have status DRAFT
+      // * they have status NEW and have any draft patch sets
       e.execute(
           String.format(
-              "UPDATE changes SET %s = 'Y', created_on = created_on WHERE status = 'd' OR "
-                  + "EXISTS (SELECT * FROM patch_sets WHERE "
-                  + "patch_sets.change_id = changes.change_id AND patch_sets.draft = 'Y')",
+              "UPDATE changes "
+                  + "SET %s = 'Y', "
+                  + "    status = 'n', "
+                  + "    created_on = created_on "
+                  + "WHERE status = 'd' "
+                  + "  OR (status = 'n' "
+                  + "      AND EXISTS "
+                  + "        (SELECT * "
+                  + "         FROM patch_sets "
+                  + "         WHERE patch_sets.change_id = changes.change_id "
+                  + "           AND patch_sets.draft = 'Y')) ",
               column));
-      // Change change status from draft to new.
-      e.execute("UPDATE changes SET status = 'n', created_on = created_on WHERE status = 'd'");
     }
     ui.message("done");
   }
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 1833f9c..3bf13d2 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Joiner;
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
@@ -48,10 +49,6 @@
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.nio.charset.Charset;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
 import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.atomic.AtomicReference;
@@ -74,8 +71,6 @@
   static final int STATUS_NOT_FOUND = PRIVATE_STATUS | 2;
   public static final int STATUS_NOT_ADMIN = PRIVATE_STATUS | 3;
 
-  private static final String MASK = "***";
-
   @Option(name = "--", usage = "end of options", handler = EndOfOptionsHandler.class)
   private boolean endOfOptions;
 
@@ -99,8 +94,6 @@
 
   @Inject private SshScope.Context context;
 
-  @Inject private SshCommandSensitiveFieldsCache cache;
-
   /** Commands declared by a plugin can be scoped by the plugin name. */
   @Inject(optional = true)
   @PluginName
@@ -119,9 +112,8 @@
   /** Unparsed command line options. */
   private String[] argv;
 
-  private List<String> maskedArgv = new ArrayList<>();
-
-  private Set<String> sensitiveParameters = new HashSet<>();
+  /** trimmed command line arguments. */
+  private String[] trimmedArgv;
 
   public BaseCommand() {
     task = Atomics.newReference();
@@ -168,20 +160,24 @@
     this.argv = argv;
   }
 
-  public List<String> getMaskedArguments() {
-    return maskedArgv;
-  }
-
-  public String getFormattedMaskedArguments(String delimiter) {
-    return String.join(delimiter, maskedArgv);
-  }
-
-  public void setMaskedArguments(List<String> argv) {
-    this.maskedArgv = argv;
-  }
-
-  public boolean isSensitiveParameter(String param) {
-    return sensitiveParameters.contains(param);
+  /**
+   * Trim the argument if it is spanning multiple lines.
+   *
+   * @return the arguments where all the multiple-line fields are trimmed.
+   */
+  protected String[] getTrimmedArguments() {
+    if (trimmedArgv == null && argv != null) {
+      trimmedArgv = new String[argv.length];
+      for (int i = 0; i < argv.length; i++) {
+        String arg = argv[i];
+        int indexOfMultiLine = arg.indexOf("\n");
+        if (indexOfMultiLine > -1) {
+          arg = arg.substring(0, indexOfMultiLine).concat(" [trimmed]");
+        }
+        trimmedArgv[i] = arg;
+      }
+    }
+    return trimmedArgv;
   }
 
   @Override
@@ -354,7 +350,7 @@
         m.append(")");
       }
       m.append(" during ");
-      m.append(getFormattedMaskedArguments(" "));
+      m.append(context.getCommandLine());
       log.error(m.toString(), e);
     }
 
@@ -399,8 +395,11 @@
   }
 
   protected String getTaskDescription() {
-    StringBuilder m = new StringBuilder();
-    m.append(getFormattedMaskedArguments(" "));
+    StringBuilder m = new StringBuilder(commandName);
+    String[] ta = getTrimmedArguments();
+    if (ta != null) {
+      m.append(Joiner.on(" ").join(ta));
+    }
     return m.toString();
   }
 
@@ -416,49 +415,12 @@
     return m.toString();
   }
 
-  private void maskSensitiveParameters() {
-    if (argv == null) {
-      return;
-    }
-    sensitiveParameters = cache.get(this.getClass());
-    maskedArgv = new ArrayList<>();
-    maskedArgv.add(commandName);
-    boolean maskNext = false;
-    for (int i = 0; i < argv.length; i++) {
-      if (maskNext) {
-        maskedArgv.add(MASK);
-        maskNext = false;
-        continue;
-      }
-      String arg = argv[i];
-      String key = extractKey(arg);
-      if (isSensitiveParameter(key)) {
-        maskNext = arg.equals(key);
-        // When arg != key then parameter contains '=' sign and we mask them right away.
-        // Otherwise we mask the next parameter as indicated by maskNext.
-        if (!maskNext) {
-          arg = key + "=" + MASK;
-        }
-      }
-      maskedArgv.add(arg);
-    }
-  }
-
-  private String extractKey(String arg) {
-    int eqPos = arg.indexOf('=');
-    if (eqPos > 0) {
-      return arg.substring(0, eqPos);
-    }
-    return arg;
-  }
-
   private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
     private final CommandRunnable thunk;
     private final String taskName;
     private Project.NameKey projectName;
 
     private TaskThunk(CommandRunnable thunk) {
-      maskSensitiveParameters();
       this.thunk = thunk;
       this.taskName = getTaskName();
     }
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index d061535..0287ceb 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -29,7 +29,6 @@
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -160,7 +159,7 @@
                   } catch (Exception e) {
                     logger.warn(
                         "Cannot start command \""
-                            + cmd.getFormattedMaskedArguments(" ")
+                            + ctx.getCommandLine()
                             + "\" for user "
                             + ctx.getSession().getUsername(),
                         e);
@@ -180,10 +179,6 @@
         try {
           cmd = dispatcher.get();
           cmd.setArguments(argv);
-          cmd.setMaskedArguments(
-              argv.length > 0
-                  ? Arrays.asList(argv[0])
-                  : Arrays.asList(ctx.getCommandLine().split(" ")[0]));
           cmd.setInputStream(in);
           cmd.setOutputStream(out);
           cmd.setErrorStream(err);
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 62f80c9..3f2e258 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -107,10 +107,6 @@
       atomicCmd.set(cmd);
       cmd.start(env);
 
-      if (cmd instanceof BaseCommand) {
-        setMaskedArguments(((BaseCommand) cmd).getMaskedArguments());
-      }
-
     } catch (UnloggedFailure e) {
       String msg = e.getMessage();
       if (!msg.endsWith("\n")) {
diff --git a/java/com/google/gerrit/sshd/SensitiveData.java b/java/com/google/gerrit/sshd/SensitiveData.java
deleted file mode 100644
index 1dd7896..0000000
--- a/java/com/google/gerrit/sshd/SensitiveData.java
+++ /dev/null
@@ -1,28 +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;
-
-import static java.lang.annotation.ElementType.FIELD;
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import java.lang.annotation.Retention;
-import java.lang.annotation.Target;
-
-/**
- * Annotation tagged on a field of an ssh command to indicate the value must be hidden from logs.
- */
-@Target({FIELD})
-@Retention(RUNTIME)
-public @interface SensitiveData {}
diff --git a/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCache.java b/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCache.java
deleted file mode 100644
index 8c79299..0000000
--- a/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCache.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd;
-
-import java.util.Set;
-
-/** Keeps data about ssh commands' parameters that have extra secure annotation. */
-public interface SshCommandSensitiveFieldsCache {
-  Set<String> get(Class<?> command);
-
-  void evictAll();
-}
diff --git a/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCacheImpl.java b/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCacheImpl.java
deleted file mode 100644
index b593388..0000000
--- a/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCacheImpl.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.sshd;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import java.lang.reflect.Field;
-import java.util.HashSet;
-import java.util.Set;
-import org.kohsuke.args4j.Option;
-
-public class SshCommandSensitiveFieldsCacheImpl implements SshCommandSensitiveFieldsCache {
-  private static final String CACHE_NAME = "sshd_sensitive_command_params";
-  private final LoadingCache<Class<?>, Set<String>> sshdCommandsCache;
-
-  static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(CACHE_NAME, new TypeLiteral<Class<?>>() {}, new TypeLiteral<Set<String>>() {})
-            .loader(Loader.class);
-        bind(SshCommandSensitiveFieldsCache.class).to(SshCommandSensitiveFieldsCacheImpl.class);
-      }
-    };
-  }
-
-  @Inject
-  SshCommandSensitiveFieldsCacheImpl(@Named(CACHE_NAME) LoadingCache<Class<?>, Set<String>> cache) {
-    sshdCommandsCache = cache;
-  }
-
-  @Override
-  public Set<String> get(Class<?> cmd) {
-    return sshdCommandsCache.getUnchecked(cmd);
-  }
-
-  @Override
-  public void evictAll() {
-    sshdCommandsCache.invalidateAll();
-  }
-
-  static class Loader extends CacheLoader<Class<?>, Set<String>> {
-
-    @Override
-    public Set<String> load(Class<?> cmd) throws Exception {
-      Set<String> datas = new HashSet<>();
-      for (Field field : cmd.getDeclaredFields()) {
-        if (field.isAnnotationPresent(SensitiveData.class)) {
-          Option option = field.getAnnotation(Option.class);
-          datas.add(option.name());
-          for (String opt : option.aliases()) {
-            datas.add(opt);
-          }
-        }
-      }
-      return datas;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/sshd/SshLog.java b/java/com/google/gerrit/sshd/SshLog.java
index 035989a..6b43d78 100644
--- a/java/com/google/gerrit/sshd/SshLog.java
+++ b/java/com/google/gerrit/sshd/SshLog.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.common.base.Joiner;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.TimeUtil;
@@ -48,12 +49,9 @@
   private static final String P_STATUS = "status";
   private static final String P_AGENT = "agent";
 
-  private static final String MASK = "***";
-
   private final Provider<SshSession> session;
   private final Provider<Context> context;
   private final AsyncAppender async;
-  private final boolean auditMask;
   private final AuditService auditService;
 
   @Inject
@@ -67,7 +65,6 @@
     this.context = context;
     this.auditService = auditService;
 
-    auditMask = config.getBoolean("audit", "maskSensitiveData", false);
     if (!config.getBoolean("sshd", "requestLog", true)) {
       async = null;
       return;
@@ -125,7 +122,8 @@
     final Context ctx = context.get();
     ctx.finished = TimeUtil.nowMs();
 
-    String cmd = extractWhat(dcmd, true);
+    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");
@@ -157,11 +155,7 @@
     if (async != null) {
       async.append(event);
     }
-
-    if (!auditMask) {
-      cmd = extractWhat(dcmd, false);
-    }
-    audit(ctx, status, cmd, extractParameters(dcmd));
+    audit(context.get(), status, dcmd);
   }
 
   private ListMultimap<String, ?> extractParameters(DispatchCommand dcmd) {
@@ -184,10 +178,7 @@
       // --param=value
       int eqPos = arg.indexOf('=');
       if (arg.startsWith("--") && eqPos > 0) {
-        String param = arg.substring(0, eqPos);
-        String value =
-            auditMask && dcmd.isSensitiveParameter(param) ? MASK : arg.substring(eqPos + 1);
-        parms.put(param, value);
+        parms.put(arg.substring(0, eqPos), arg.substring(eqPos + 1));
         continue;
       }
       // -p value or --param value
@@ -202,7 +193,7 @@
       if (paramName == null) {
         parms.put("$" + argPos++, arg);
       } else {
-        parms.put(paramName, auditMask && dcmd.isSensitiveParameter(paramName) ? MASK : arg);
+        parms.put(paramName, arg);
         paramName = null;
       }
     }
@@ -266,6 +257,10 @@
     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;
@@ -283,19 +278,14 @@
     auditService.dispatch(new SshAuditEvent(sessionId, currentUser, cmd, created, params, result));
   }
 
-  private String extractWhat(DispatchCommand dcmd, boolean hideSensitive) {
+  private String extractWhat(DispatchCommand dcmd) {
     if (dcmd == null) {
       return "Command was already destroyed";
     }
-    return hideSensitive ? dcmd.getFormattedMaskedArguments(".") : extractWhat(dcmd);
-  }
-
-  private String extractWhat(DispatchCommand dcmd) {
-    String name = dcmd.getCommandName();
-    StringBuilder commandName = new StringBuilder(name == null ? "" : name);
-    String[] args = dcmd.getArguments();
-    for (int i = 1; i < args.length; i++) {
-      commandName.append(".").append(args[i]);
+    StringBuilder commandName = new StringBuilder(dcmd.getCommandName());
+    String[] trimmedArgs = dcmd.getTrimmedArguments();
+    if (trimmedArgs != null) {
+      commandName.append(Joiner.on(".").join(trimmedArgs));
     }
     return commandName.toString();
   }
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index c672b00..4ad8ba3 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.registerInParentInjectors;
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.DynamicOptions;
@@ -37,6 +39,7 @@
 import com.google.inject.servlet.RequestScoped;
 import java.net.SocketAddress;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.apache.sshd.server.CommandFactory;
@@ -65,7 +68,6 @@
     configureRequestScope();
     install(new AsyncReceiveCommits.Module());
     configureAliases();
-    install(SshCommandSensitiveFieldsCacheImpl.module());
 
     bind(SshLog.class);
     bind(SshInfo.class).to(SshDaemon.class).in(SINGLETON);
@@ -109,10 +111,10 @@
     CommandName gerrit = Commands.named("gerrit");
     for (Map.Entry<String, String> e : aliases.entrySet()) {
       String name = e.getKey();
-      String[] dest = e.getValue().split("[ \\t]+");
-      CommandName cmd = Commands.named(dest[0]);
-      for (int i = 1; i < dest.length; i++) {
-        cmd = Commands.named(cmd, dest[i]);
+      List<String> dest = Splitter.on(CharMatcher.whitespace()).splitToList(e.getValue());
+      CommandName cmd = Commands.named(dest.get(0));
+      for (int i = 1; i < dest.size(); i++) {
+        cmd = Commands.named(cmd, dest.get(i));
       }
       bind(Commands.key(gerrit, name)).toProvider(new AliasCommandProvider(cmd));
     }
diff --git a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index 047690c..9ae1814 100644
--- a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -33,16 +33,13 @@
 
   private final DispatchCommandProvider root;
   private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
-  private final SshCommandSensitiveFieldsCache cache;
 
   @Inject
   SshPluginStarterCallback(
       @CommandName(Commands.ROOT) DispatchCommandProvider root,
-      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
-      SshCommandSensitiveFieldsCache cache) {
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.root = root;
     this.dynamicBeans = dynamicBeans;
-    this.cache = cache;
   }
 
   @Override
@@ -59,7 +56,6 @@
     if (cmd != null) {
       newPlugin.add(root.replace(Commands.named(newPlugin.getName()), cmd));
     }
-    cache.evictAll();
   }
 
   private Provider<Command> load(Plugin plugin) {
diff --git a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index 2051a00..fa4a573 100644
--- a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -227,9 +227,9 @@
       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) {
+      List<String> s = Splitter.on('=').splitToList(pluginConfigValue);
+      List<String> s2 = Splitter.on('.').splitToList(s.get(0));
+      if (s.size() != 2 || s2.size() != 2) {
         throw die(
             "Invalid plugin config value '"
                 + pluginConfigValue
@@ -237,14 +237,14 @@
                 + " or '<plugin-name>.<parameter-name>=<value1,value2,...>'");
       }
       ConfigValue value = new ConfigValue();
-      String v = s[1];
+      String v = s.get(1);
       if (v.contains(",")) {
-        value.values = Lists.newArrayList(Splitter.on(",").split(v));
+        value.values = Splitter.on(",").splitToList(v);
       } else {
         value.value = v;
       }
-      String pluginName = s2[0];
-      String paramName = s2[1];
+      String pluginName = s2.get(0);
+      String paramName = s2.get(1);
       Map<String, ConfigValue> l = m.get(pluginName);
       if (l == null) {
         l = new HashMap<>();
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index e467cc4..b51e178 100644
--- a/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -24,7 +24,9 @@
 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.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -49,7 +51,7 @@
 public class LsUserRefs extends SshCommand {
   @Inject private AccountResolver accountResolver;
   @Inject private OneOffRequestContext requestContext;
-  @Inject private VisibleRefFilter.Factory refFilterFactory;
+  @Inject private PermissionBackend permissionBackend;
   @Inject private GitRepositoryManager repoManager;
 
   @Option(
@@ -92,16 +94,17 @@
         ManualRequestContext ctx = requestContext.openAs(userAccount.getId())) {
       try {
         Map<String, Ref> refsMap =
-            refFilterFactory
-                .create(projectState, repo)
-                .filter(repo.getRefDatabase().getRefs(ALL), false);
+            permissionBackend
+                .user(user)
+                .project(projectName)
+                .filter(repo.getRefDatabase().getRefs(ALL), repo, RefFilterOptions.defaults());
 
         for (String ref : refsMap.keySet()) {
           if (!onlyRefsHeads || ref.startsWith(RefNames.REFS_HEADS)) {
             stdout.println(ref);
           }
         }
-      } catch (IOException e) {
+      } catch (IOException | PermissionBackendException e) {
         throw new Failure(1, "fatal: Error reading refs: '" + projectName, e);
       }
     } catch (RepositoryNotFoundException e) {
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
index 0e6ac49..a455c90 100644
--- a/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -20,7 +20,7 @@
 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.DefaultAdvertiseRefsHook;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -134,14 +134,14 @@
       msg.append("  AdvertiseRefsHook: ").append(rp.getAdvertiseRefsHook());
       if (rp.getAdvertiseRefsHook() == AdvertiseRefsHook.DEFAULT) {
         msg.append("DEFAULT");
-      } else if (rp.getAdvertiseRefsHook() instanceof VisibleRefFilter) {
-        msg.append("VisibleRefFilter");
+      } else if (rp.getAdvertiseRefsHook() instanceof DefaultAdvertiseRefsHook) {
+        msg.append("DefaultAdvertiseRefsHook");
       } else {
         msg.append(rp.getAdvertiseRefsHook().getClass());
       }
       msg.append("\n");
 
-      if (rp.getAdvertiseRefsHook() instanceof VisibleRefFilter) {
+      if (rp.getAdvertiseRefsHook() instanceof DefaultAdvertiseRefsHook) {
         Map<String, Ref> adv = rp.getAdvertisedRefs();
         msg.append("  Visible references (").append(adv.size()).append("):\n");
         for (Ref ref : adv.values()) {
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 1be32a8..1d764b9 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -240,11 +240,6 @@
     }
   }
 
-  @Override
-  protected String getTaskDescription() {
-    return "gerrit review";
-  }
-
   private void applyReview(PatchSet patchSet, ReviewInput review) throws RestApiException {
     gApi.changes()
         .id(patchSet.getId().getParentKey().get())
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index 51c3ef4..c5485ef 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -175,7 +175,7 @@
   }
 
   private static String time(long now, long time) {
-    if (time - now < 24 * 60 * 60 * 1000L) {
+    if (now - time < 24 * 60 * 60 * 1000L) {
       return new SimpleDateFormat("HH:mm:ss").format(new Date(time));
     }
     return new SimpleDateFormat("MMM-dd").format(new Date(time));
diff --git a/java/com/google/gerrit/sshd/commands/Upload.java b/java/com/google/gerrit/sshd/commands/Upload.java
index 0d78279..24a6975 100644
--- a/java/com/google/gerrit/sshd/commands/Upload.java
+++ b/java/com/google/gerrit/sshd/commands/Upload.java
@@ -17,12 +17,13 @@
 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.DefaultAdvertiseRefsHook;
 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.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.sshd.AbstractGitCommand;
@@ -39,7 +40,6 @@
 /** 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;
@@ -49,11 +49,11 @@
 
   @Override
   protected void runImpl() throws IOException, Failure {
+    PermissionBackend.ForProject perm =
+        permissionBackend.user(user).project(projectState.getNameKey());
     try {
-      permissionBackend
-          .user(user)
-          .project(projectState.getNameKey())
-          .check(ProjectPermission.RUN_UPLOAD_PACK);
+
+      perm.check(ProjectPermission.RUN_UPLOAD_PACK);
     } catch (AuthException e) {
       throw new Failure(1, "fatal: upload-pack not permitted on this server");
     } catch (PermissionBackendException e) {
@@ -61,7 +61,7 @@
     }
 
     final UploadPack up = new UploadPack(repo);
-    up.setAdvertiseRefsHook(refFilterFactory.create(projectState, repo));
+    up.setAdvertiseRefsHook(new DefaultAdvertiseRefsHook(perm, RefFilterOptions.defaults()));
     up.setPackConfig(config.getPackConfig());
     up.setTimeout(config.getTimeout());
     up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index acb2eca..9ac522f 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.change.ArchiveFormat;
@@ -142,8 +143,7 @@
       if (!s.startsWith(argCmd)) {
         throw new Failure(1, "fatal: 'argument' token or flush expected, got " + s);
       }
-      String[] parts = s.substring(argCmd.length()).split("=", 2);
-      for (String p : parts) {
+      for (String p : Splitter.on('=').limit(2).split(s.substring(argCmd.length()))) {
         args.add(p);
       }
     }
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index 7aed684..19e85db 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -23,6 +23,7 @@
 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.MailHeader;
 import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.AbstractModule;
@@ -148,8 +149,8 @@
   }
 
   public List<Message> getMessages(String changeId, String type) {
-    final String idFooter = "\nGerrit-Change-Id: " + changeId + "\n";
-    final String typeFooter = "\nGerrit-MessageType: " + type + "\n";
+    final String idFooter = "\n" + MailHeader.CHANGE_ID.withDelimiter() + changeId + "\n";
+    final String typeFooter = "\n" + MailHeader.MESSAGE_TYPE.withDelimiter() + type + "\n";
     return getMessages()
         .stream()
         .filter(in -> in.body().contains(idFooter) && in.body().contains(typeFooter))
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index f6f18bd..3bea0a8 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -281,9 +281,11 @@
 
   private Module indexModule(String moduleClassName) {
     try {
+      boolean slave = cfg.getBoolean("container", "slave", false);
       Class<?> clazz = Class.forName(moduleClassName);
-      Method m = clazz.getMethod("singleVersionWithExplicitVersions", Map.class, int.class);
-      return (Module) m.invoke(null, getSingleSchemaVersions(), 0);
+      Method m =
+          clazz.getMethod("singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
+      return (Module) m.invoke(null, getSingleSchemaVersions(), 0, slave);
     } catch (ClassNotFoundException
         | SecurityException
         | NoSuchMethodException
diff --git a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
index bffbcd6..cebd139 100644
--- a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
+++ b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
@@ -121,7 +121,9 @@
       schemaCreator.create(underlyingDb);
     }
     db = schemaFactory.open();
-    setApiUser(accountManager.authenticate(AuthRequest.forUser("user")).getAccountId());
+
+    // The first user is added to the "Administrators" group. See AccountManager#create().
+    setApiUser(accountManager.authenticate(AuthRequest.forUser("admin")).getAccountId());
 
     // Inject target members after setting API user, so it can @Inject a ReviewDb if it wants.
     injector.injectMembers(target);
diff --git a/java/gerrit/PRED_current_user_1.java b/java/gerrit/PRED_current_user_1.java
deleted file mode 100644
index c7d381d..0000000
--- a/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.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
deleted file mode 100644
index 4815b9f..0000000
--- a/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.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/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index de73551..9c89429 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -1558,9 +1558,20 @@
 
   @Test
   public void implicitlyCcOnNonVotingReviewPgStyle() throws Exception {
+    testImplicitlyCcOnNonVotingReviewPgStyle(user);
+  }
+
+  @Test
+  public void implicitlyCcOnNonVotingReviewForUserWithoutUserNamePgStyle() throws Exception {
+    TestAccount accountWithoutUsername = accountCreator.create();
+    assertThat(accountWithoutUsername.username).isNull();
+    testImplicitlyCcOnNonVotingReviewPgStyle(accountWithoutUsername);
+  }
+
+  private void testImplicitlyCcOnNonVotingReviewPgStyle(TestAccount testAccount) throws Exception {
     PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    assertThat(getReviewerState(r.getChangeId(), user.id)).isEmpty();
+    setApiUser(testAccount);
+    assertThat(getReviewerState(r.getChangeId(), testAccount.id)).isEmpty();
 
     // Exact request format made by PG UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
     ReviewInput in = new ReviewInput();
@@ -1571,15 +1582,26 @@
     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))
+    assertThat(getReviewerState(r.getChangeId(), testAccount.id))
         .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
   }
 
   @Test
   public void implicitlyCcOnNonVotingReviewGwtStyle() throws Exception {
+    testImplicitlyCcOnNonVotingReviewGwtStyle(user);
+  }
+
+  @Test
+  public void implicitlyCcOnNonVotingReviewForUserWithoutUserNameGwtStyle() throws Exception {
+    TestAccount accountWithoutUsername = accountCreator.create();
+    assertThat(accountWithoutUsername.username).isNull();
+    testImplicitlyCcOnNonVotingReviewGwtStyle(accountWithoutUsername);
+  }
+
+  private void testImplicitlyCcOnNonVotingReviewGwtStyle(TestAccount testAccount) throws Exception {
     PushOneCommit.Result r = createChange();
-    setApiUser(user);
-    assertThat(getReviewerState(r.getChangeId(), user.id)).isEmpty();
+    setApiUser(testAccount);
+    assertThat(getReviewerState(r.getChangeId(), testAccount.id)).isEmpty();
 
     // Exact request format made by GWT UI at ddc6b7160fe416fed9e7e3180489d44c82fd64f8.
     ReviewInput in = new ReviewInput();
@@ -1589,7 +1611,7 @@
     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))
+    assertThat(getReviewerState(r.getChangeId(), testAccount.id))
         .hasValue(notesMigration.readChanges() ? CC : REVIEWER);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
index 781122b..21294f5 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -7,6 +7,8 @@
     deps = [
         ":util",
         "//java/com/google/gerrit/server/group/db/testing",
+        "//java/com/google/gerrit/server/group/testing",
+        "//java/com/google/gerrit/truth",
         "//javatests/com/google/gerrit/acceptance/rest/account:util",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
new file mode 100644
index 0000000..389efb4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -0,0 +1,191 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.group;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+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 com.google.gerrit.truth.ListSubject.assertThat;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.group.testing.InternalGroupSubject;
+import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.gerrit.server.query.group.InternalGroupQuery;
+import com.google.gerrit.testing.InMemoryTestEnvironment;
+import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.OptionalSubject;
+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.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class GroupIndexerIT {
+  private static Config createPureNoteDbConfig() {
+    Config config = new Config();
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, true);
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, true);
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, true);
+    return config;
+  }
+
+  @Rule
+  public InMemoryTestEnvironment testEnvironment =
+      new InMemoryTestEnvironment(GroupIndexerIT::createPureNoteDbConfig);
+
+  @Inject private GroupIndexer groupIndexer;
+  @Inject private GerritApi gApi;
+  @Inject private GroupCache groupCache;
+  @Inject private ReviewDb db;
+  @Inject @ServerInitiated private GroupsUpdate groupsUpdate;
+  @Inject private Provider<InternalGroupQuery> groupQueryProvider;
+
+  @Test
+  public void indexingUpdatesTheIndex() throws Exception {
+    AccountGroup.UUID groupUuid = createGroup("users");
+    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    updateGroupWithoutCacheOrIndex(
+        groupUuid,
+        newGroupUpdate()
+            .setSubgroupModification(subgroups -> ImmutableSet.of(subgroupUuid))
+            .build());
+
+    groupIndexer.index(groupUuid);
+
+    List<InternalGroup> parentGroups = groupQueryProvider.get().bySubgroup(subgroupUuid);
+    assertThatGroups(parentGroups).onlyElement().groupUuid().isEqualTo(groupUuid);
+  }
+
+  @Test
+  public void indexCannotBeCorruptedByStaleCache() throws Exception {
+    AccountGroup.UUID groupUuid = createGroup("verifiers");
+    loadGroupToCache(groupUuid);
+    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    updateGroupWithoutCacheOrIndex(
+        groupUuid,
+        newGroupUpdate()
+            .setSubgroupModification(subgroups -> ImmutableSet.of(subgroupUuid))
+            .build());
+
+    groupIndexer.index(groupUuid);
+
+    List<InternalGroup> parentGroups = groupQueryProvider.get().bySubgroup(subgroupUuid);
+    assertThatGroups(parentGroups).onlyElement().groupUuid().isEqualTo(groupUuid);
+  }
+
+  @Test
+  public void indexingUpdatesStaleUuidCache() throws Exception {
+    AccountGroup.UUID groupUuid = createGroup("verifiers");
+    loadGroupToCache(groupUuid);
+    updateGroupWithoutCacheOrIndex(groupUuid, newGroupUpdate().setDescription("Modified").build());
+
+    groupIndexer.index(groupUuid);
+
+    Optional<InternalGroup> updatedGroup = groupCache.get(groupUuid);
+    assertThatGroup(updatedGroup).value().description().isEqualTo("Modified");
+  }
+
+  @Test
+  public void reindexingStaleGroupUpdatesTheIndex() throws Exception {
+    AccountGroup.UUID groupUuid = createGroup("users");
+    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("contributors");
+    updateGroupWithoutCacheOrIndex(
+        groupUuid,
+        newGroupUpdate()
+            .setSubgroupModification(subgroups -> ImmutableSet.of(subgroupUuid))
+            .build());
+
+    groupIndexer.reindexIfStale(groupUuid);
+
+    List<InternalGroup> parentGroups = groupQueryProvider.get().bySubgroup(subgroupUuid);
+    assertThatGroups(parentGroups).onlyElement().groupUuid().isEqualTo(groupUuid);
+  }
+
+  @Test
+  public void notStaleGroupIsNotReindexed() throws Exception {
+    AccountGroup.UUID groupUuid = createGroup("verifiers");
+    updateGroupWithoutCacheOrIndex(groupUuid, newGroupUpdate().setDescription("Modified").build());
+    groupIndexer.index(groupUuid);
+
+    boolean reindexed = groupIndexer.reindexIfStale(groupUuid);
+
+    assertWithMessage("Group should not have been reindexed").that(reindexed).isFalse();
+  }
+
+  @Test
+  public void indexStalenessIsNotDerivedFromCacheStaleness() throws Exception {
+    AccountGroup.UUID groupUuid = createGroup("verifiers");
+    updateGroupWithoutCacheOrIndex(groupUuid, newGroupUpdate().setDescription("Modified").build());
+    reloadGroupToCache(groupUuid);
+
+    boolean reindexed = groupIndexer.reindexIfStale(groupUuid);
+
+    assertWithMessage("Group should have been reindexed").that(reindexed).isTrue();
+  }
+
+  private AccountGroup.UUID createGroup(String name) throws RestApiException {
+    GroupInfo group = gApi.groups().create(name).get();
+    return new AccountGroup.UUID(group.id);
+  }
+
+  private void reloadGroupToCache(AccountGroup.UUID groupUuid) {
+    groupCache.evict(groupUuid);
+    loadGroupToCache(groupUuid);
+  }
+
+  private void loadGroupToCache(AccountGroup.UUID groupUuid) {
+    groupCache.get(groupUuid);
+  }
+
+  private static InternalGroupUpdate.Builder newGroupUpdate() {
+    return InternalGroupUpdate.builder();
+  }
+
+  private void updateGroupWithoutCacheOrIndex(
+      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+    groupsUpdate.updateGroupInDb(db, groupUuid, groupUpdate);
+  }
+
+  private static OptionalSubject<InternalGroupSubject, InternalGroup> assertThatGroup(
+      Optional<InternalGroup> updatedGroup) {
+    return assertThat(updatedGroup, InternalGroupSubject::assertThat);
+  }
+
+  private static ListSubject<InternalGroupSubject, InternalGroup> assertThatGroups(
+      List<InternalGroup> parentGroups) {
+    return assertThat(parentGroups, InternalGroupSubject::assertThat);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 39d927e..2d5703d 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -33,9 +33,9 @@
 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.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -60,6 +60,9 @@
 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.events.GroupIndexedListener;
+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;
@@ -71,11 +74,17 @@
 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.Sequences;
+import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsConsistencyChecker;
+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.GroupIndexer;
 import com.google.gerrit.server.index.group.StalenessChecker;
 import com.google.gerrit.server.util.MagicBranch;
@@ -83,7 +92,6 @@
 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;
@@ -94,7 +102,6 @@
 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;
@@ -132,14 +139,14 @@
   }
 
   @Inject private Groups groups;
+  @Inject @ServerInitiated private GroupsUpdate groupsUpdate;
   @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;
+  @Inject private PeriodicGroupIndexer slaveGroupIndexer;
+  @Inject private DynamicSet<GroupIndexedListener> groupIndexedListeners;
+  @Inject private Sequences seq;
 
   @Before
   public void setTimeForTesting() {
@@ -1227,8 +1234,7 @@
   @Test
   @Sandboxed
   public void groupsOfUserCanBeListedInSlaveMode() throws Exception {
-    // TODO(aliceks): Remove this line when we have a group index in slave mode.
-    assume().that(readGroupsFromNoteDb()).isFalse();
+    assume().that(readGroupsFromNoteDb()).isTrue();
 
     GroupInput groupInput = new GroupInput();
     groupInput.name = name("contributors");
@@ -1243,11 +1249,96 @@
     assertThat(groupNames).contains(groupInput.name);
   }
 
+  @Test
+  @Sandboxed
+  @GerritConfig(name = "index.scheduledIndexer.enabled", value = "false")
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
+  @IgnoreGroupInconsistencies
+  public void reindexGroupsInSlaveMode() throws Exception {
+    assume().that(readGroupsFromNoteDb()).isTrue();
+    assume().that(cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, false)).isTrue();
+
+    List<AccountGroup.UUID> expectedGroups =
+        groups.getAllGroupReferences(db).map(GroupReference::getUUID).collect(toList());
+    assertThat(expectedGroups.size()).isAtLeast(2);
+
+    // Restart the server as slave, on startup of the slave all groups are indexed.
+    restartAsSlave();
+
+    GroupIndexedCounter groupIndexedCounter = new GroupIndexedCounter();
+    RegistrationHandle groupIndexEventCounterHandle =
+        groupIndexedListeners.add(groupIndexedCounter);
+    try {
+      // Running the reindexer right after startup should not need to reindex any group since
+      // reindexing was already done on startup.
+      slaveGroupIndexer.run();
+      groupIndexedCounter.assertNoReindex();
+
+      // Create a group without updating the cache or index,
+      // then run the reindexer -> only the new group is reindexed.
+      String groupName = "foo";
+      AccountGroup.UUID groupUuid = new AccountGroup.UUID(groupName + "-UUID");
+      groupsUpdate.createGroupInNoteDb(
+          InternalGroupCreation.builder()
+              .setGroupUUID(groupUuid)
+              .setNameKey(new AccountGroup.NameKey(groupName))
+              .setId(new AccountGroup.Id(seq.nextGroupId()))
+              .build(),
+          InternalGroupUpdate.builder().build());
+      slaveGroupIndexer.run();
+      groupIndexedCounter.assertReindexOf(groupUuid);
+
+      // Update a group without updating the cache or index,
+      // then run the reindexer -> only the updated group is reindexed.
+      groupsUpdate.updateGroupInDb(
+          db, groupUuid, InternalGroupUpdate.builder().setDescription("bar").build());
+      slaveGroupIndexer.run();
+      groupIndexedCounter.assertReindexOf(groupUuid);
+
+      // Delete a group  without updating the cache or index,
+      // then run the reindexer -> only the deleted group is reindexed.
+      try (Repository repo = repoManager.openRepository(allUsers)) {
+        RefUpdate u = repo.updateRef(RefNames.refsGroups(groupUuid));
+        u.setForceUpdate(true);
+        assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
+      }
+      slaveGroupIndexer.run();
+      groupIndexedCounter.assertReindexOf(groupUuid);
+    } finally {
+      groupIndexEventCounterHandle.remove();
+    }
+  }
+
+  @Test
+  @Sandboxed
+  @GerritConfig(name = "index.scheduledIndexer.runOnStartup", value = "false")
+  @GerritConfig(name = "index.scheduledIndexer.enabled", value = "false")
+  @GerritConfig(name = "index.autoReindexIfStale", value = "false")
+  @IgnoreGroupInconsistencies
+  public void disabledReindexGroupsOnStartupSlaveMode() throws Exception {
+    assume().that(readGroupsFromNoteDb()).isTrue();
+
+    List<AccountGroup.UUID> expectedGroups =
+        groups.getAllGroupReferences(db).map(GroupReference::getUUID).collect(toList());
+    assertThat(expectedGroups.size()).isAtLeast(2);
+
+    restartAsSlave();
+
+    GroupIndexedCounter groupIndexedCounter = new GroupIndexedCounter();
+    RegistrationHandle groupIndexEventCounterHandle =
+        groupIndexedListeners.add(groupIndexedCounter);
+    try {
+      // No group indexing happened on startup. All groups should be reindexed now.
+      slaveGroupIndexer.run();
+      groupIndexedCounter.assertReindexOf(expectedGroups);
+    } finally {
+      groupIndexEventCounterHandle.remove();
+    }
+  }
+
   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());
+    // Evict group from cache to be sure that we use the index state for staleness checks.
+    groupCache.evict(groupUuid);
     assertThat(stalenessChecker.isStale(groupUuid)).isTrue();
 
     // Reindex fixes staleness
@@ -1402,4 +1493,38 @@
   @Target({METHOD})
   @Retention(RUNTIME)
   private @interface IgnoreGroupInconsistencies {}
+
+  /** Checks if a group is indexed the correct number of times. */
+  private static class GroupIndexedCounter implements GroupIndexedListener {
+    private final AtomicLongMap<String> countsByGroup = AtomicLongMap.create();
+
+    @Override
+    public void onGroupIndexed(String uuid) {
+      countsByGroup.incrementAndGet(uuid);
+    }
+
+    void clear() {
+      countsByGroup.clear();
+    }
+
+    long getCount(AccountGroup.UUID groupUuid) {
+      return countsByGroup.get(groupUuid.get());
+    }
+
+    void assertReindexOf(AccountGroup.UUID groupUuid) {
+      assertReindexOf(ImmutableList.of(groupUuid));
+    }
+
+    void assertReindexOf(List<AccountGroup.UUID> groupUuids) {
+      for (AccountGroup.UUID groupUuid : groupUuids) {
+        assertThat(getCount(groupUuid)).named(groupUuid.get()).isEqualTo(1);
+      }
+      assertThat(countsByGroup).hasSize(groupUuids.size());
+      clear();
+    }
+
+    void assertNoReindex() {
+      assertThat(countsByGroup).isEmpty();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 0b2e5fb..4791d4c 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -98,47 +98,48 @@
 
   @Test
   public void nonexistentPermission() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("not recognized");
-
     AccessCheckInput in = new AccessCheckInput();
     in.account = user.email;
     in.permission = "notapermission";
     in.ref = "refs/heads/master";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("not recognized");
     gApi.projects().name(normalProject.get()).checkAccess(in);
   }
 
   @Test
   public void permissionLacksRef() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("must set 'ref'");
     AccessCheckInput in = new AccessCheckInput();
     in.account = user.email;
     in.permission = "forge_author";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("must set 'ref'");
     gApi.projects().name(normalProject.get()).checkAccess(in);
   }
 
   @Test
   public void changePermission() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("recognized as ref permission");
-
     AccessCheckInput in = new AccessCheckInput();
     in.account = user.email;
     in.permission = "rebase";
     in.ref = "refs/heads/master";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("recognized as ref permission");
     gApi.projects().name(normalProject.get()).checkAccess(in);
   }
 
   @Test
   public void nonexistentEmail() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("cannot find account doesnotexist@invalid.com");
-
     AccessCheckInput in = new AccessCheckInput();
     in.account = "doesnotexist@invalid.com";
     in.permission = "rebase";
     in.ref = "refs/heads/master";
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("cannot find account doesnotexist@invalid.com");
     gApi.projects().name(normalProject.get()).checkAccess(in);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 081eed3..e0c16e0f 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -48,10 +48,11 @@
 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.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.NoteDbMode;
@@ -77,7 +78,7 @@
 
 @NoHttpd
 public class RefAdvertisementIT extends AbstractDaemonTest {
-  @Inject private VisibleRefFilter.Factory refFilterFactory;
+  @Inject private PermissionBackend permissionBackend;
   @Inject private ChangeNoteUtil noteUtil;
   @Inject @AnonymousCowardName private String anonymousCowardName;
   @Inject private AllUsersName allUsersName;
@@ -346,7 +347,7 @@
     try (Repository repo = repoManager.openRepository(project)) {
       assertRefs(
           repo,
-          refFilterFactory.create(projectCache.get(project), repo),
+          permissionBackend.user(user(user)).project(project),
           // Can't use stored values from the index so DB must be enabled.
           false,
           "HEAD",
@@ -369,12 +370,10 @@
   public void uploadPackSequencesWithAccessDatabase() throws Exception {
     assume().that(notesMigration.readChangeSequence()).isTrue();
     try (Repository repo = repoManager.openRepository(allProjects)) {
-      setApiUser(user);
-      assertRefs(repo, newFilter(repo, allProjects), true);
+      assertRefs(repo, newFilter(allProjects, user), true);
 
       allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
-      setApiUser(user);
-      assertRefs(repo, newFilter(repo, allProjects), true, "refs/sequences/changes");
+      assertRefs(repo, newFilter(allProjects, user), true, "refs/sequences/changes");
     }
   }
 
@@ -665,10 +664,13 @@
     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())
+      PermissionBackend.ForProject forProject = newFilter(allUsers, admin);
+      assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
+          .containsExactlyElementsIn(expectedAllRefs);
+      assertThat(
+              forProject
+                  .filter(all, repo, RefFilterOptions.builder().setFilterMeta(true).build())
+                  .keySet())
           .containsExactlyElementsIn(expectedNonMetaRefs);
     }
   }
@@ -706,13 +708,15 @@
    */
   private void assertUploadPackRefs(String... expectedWithMeta) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      assertRefs(
-          repo, refFilterFactory.create(projectCache.get(project), repo), true, expectedWithMeta);
+      assertRefs(repo, permissionBackend.user(user(user)).project(project), true, expectedWithMeta);
     }
   }
 
   private void assertRefs(
-      Repository repo, VisibleRefFilter filter, boolean disableDb, String... expectedWithMeta)
+      Repository repo,
+      PermissionBackend.ForProject forProject,
+      boolean disableDb,
+      String... expectedWithMeta)
       throws Exception {
     List<String> expected = new ArrayList<>(expectedWithMeta.length);
     for (String r : expectedWithMeta) {
@@ -727,7 +731,8 @@
     }
     try {
       Map<String, Ref> all = repo.getAllRefs();
-      assertThat(filter.filter(all, false).keySet()).containsExactlyElementsIn(expected);
+      assertThat(forProject.filter(all, repo, RefFilterOptions.defaults()).keySet())
+          .containsExactlyElementsIn(expected);
     } finally {
       if (disableDb) {
         enableDb(ctx);
@@ -743,8 +748,8 @@
     }
   }
 
-  private VisibleRefFilter newFilter(Repository repo, Project.NameKey project) {
-    return refFilterFactory.create(projectCache.get(project), repo);
+  private PermissionBackend.ForProject newFilter(Project.NameKey project, TestAccount u) {
+    return permissionBackend.user(user(u)).project(project);
   }
 
   private static ObjectId obj(ChangeData cd, int psNum) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
index f2a5d2f..4b6f8b2 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.extensions.client.ListGroupsOption.MEMBERS;
 
@@ -27,6 +28,7 @@
 import com.google.gerrit.acceptance.pgm.IndexUpgradeController.UpgradeAttempt;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.index.GerritIndexStatus;
@@ -36,6 +38,8 @@
 import com.google.inject.Provider;
 import java.nio.file.Files;
 import java.util.Set;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 import org.junit.Test;
@@ -80,6 +84,91 @@
   }
 
   @Test
+  public void offlineReindexForChangesIsNotPossibleInSlaveMode() throws Exception {
+    enableSlaveMode();
+
+    int exitCode =
+        runGerritAndReturnExitCode(
+            "reindex",
+            "--index",
+            "changes",
+            "-d",
+            sitePaths.site_path.toString(),
+            "--show-stack-trace");
+
+    assertWithMessage("Slave hosts shouldn't allow to offline reindex changes")
+        .that(exitCode)
+        .isGreaterThan(0);
+  }
+
+  @Test
+  public void offlineReindexForAccountsIsNotPossibleInSlaveMode() throws Exception {
+    enableSlaveMode();
+
+    int exitCode =
+        runGerritAndReturnExitCode(
+            "reindex",
+            "--index",
+            "accounts",
+            "-d",
+            sitePaths.site_path.toString(),
+            "--show-stack-trace");
+
+    assertWithMessage("Slave hosts shouldn't allow to offline reindex accounts")
+        .that(exitCode)
+        .isGreaterThan(0);
+  }
+
+  @Test
+  public void offlineReindexForProjectsIsNotPossibleInSlaveMode() throws Exception {
+    enableSlaveMode();
+
+    int exitCode =
+        runGerritAndReturnExitCode(
+            "reindex",
+            "--index",
+            "projects",
+            "-d",
+            sitePaths.site_path.toString(),
+            "--show-stack-trace");
+
+    assertWithMessage("Slave hosts shouldn't allow to offline reindex projects")
+        .that(exitCode)
+        .isGreaterThan(0);
+  }
+
+  @Test
+  public void offlineReindexForGroupsIsPossibleInSlaveMode() throws Exception {
+    enableSlaveMode();
+
+    int exitCode =
+        runGerritAndReturnExitCode(
+            "reindex",
+            "--index",
+            "groups",
+            "-d",
+            sitePaths.site_path.toString(),
+            "--show-stack-trace");
+
+    assertWithMessage("Slave hosts should allow to offline reindex groups")
+        .that(exitCode)
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void offlineReindexForAllAvailableIndicesIsPossibleInSlaveMode() throws Exception {
+    enableSlaveMode();
+
+    int exitCode =
+        runGerritAndReturnExitCode(
+            "reindex", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+
+    assertWithMessage("Slave hosts should allow to perform a general offline reindex")
+        .that(exitCode)
+        .isEqualTo(0);
+  }
+
+  @Test
   public void onlineUpgradeChanges() throws Exception {
     int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
     int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
@@ -143,12 +232,24 @@
   }
 
   private void setOnlineUpgradeConfig(boolean enable) throws Exception {
+    updateConfig(cfg -> cfg.setBoolean("index", null, "onlineUpgrade", enable));
+  }
+
+  private void enableSlaveMode() throws Exception {
+    updateConfig(config -> config.setBoolean("container", null, "slave", true));
+  }
+
+  private void updateConfig(Consumer<Config> configConsumer) throws Exception {
     FileBasedConfig cfg = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
     cfg.load();
-    cfg.setBoolean("index", null, "onlineUpgrade", enable);
+    configConsumer.accept(cfg);
     cfg.save();
   }
 
+  private static int runGerritAndReturnExitCode(String... args) throws Exception {
+    return GerritLauncher.mainImpl(args);
+  }
+
   private void assertSearchVersion(ServerContext ctx, int expected) {
     assertThat(
             ctx.getInjector()
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index af609a6..dce65e1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -107,8 +107,31 @@
   }
 
   @Test
+  public void createEmptyChange_InvalidSubject() throws Exception {
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    ci.subject = "Change-Id: I1234000000000000000000000000000000000000";
+    assertCreateFails(
+        ci,
+        ResourceConflictException.class,
+        "missing subject; Change-Id must be in commit message footer");
+  }
+
+  @Test
   public void createNewChange() throws Exception {
-    assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+    ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .contains("Change-Id: " + info.changeId);
+  }
+
+  @Test
+  public void createNewChangeWithChangeId() throws Exception {
+    ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+    String changeId = "I1234000000000000000000000000000000000000";
+    String changeIdLine = "Change-Id: " + changeId;
+    ci.subject = "Subject\n\n" + changeIdLine;
+    ChangeInfo info = assertCreateSucceeds(ci);
+    assertThat(info.changeId).isEqualTo(changeId);
+    assertThat(info.revisions.get(info.currentRevision).commit.message).contains(changeIdLine);
   }
 
   @Test
@@ -136,14 +159,38 @@
 
   @Test
   public void createNewChangeSignedOffByFooter() throws Exception {
-    setSignedOffByFooter();
+    setSignedOffByFooter(true);
+    try {
+      ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
+      String message = info.revisions.get(info.currentRevision).commit.message;
+      assertThat(message)
+          .contains(
+              String.format(
+                  "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.getIdent().getEmailAddress()));
+    } finally {
+      setSignedOffByFooter(false);
+    }
+  }
 
-    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 createNewChangeSignedOffByFooterWithChangeId() throws Exception {
+    setSignedOffByFooter(true);
+    try {
+      ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+      String changeId = "I1234000000000000000000000000000000000000";
+      String changeIdLine = "Change-Id: " + changeId;
+      ci.subject = "Subject\n\n" + changeIdLine;
+      ChangeInfo info = assertCreateSucceeds(ci);
+      assertThat(info.changeId).isEqualTo(changeId);
+      String message = info.revisions.get(info.currentRevision).commit.message;
+      assertThat(message).contains(changeIdLine);
+      assertThat(message)
+          .contains(
+              String.format(
+                  "%sAdministrator <%s>", SIGNED_OFF_BY_TAG, admin.getIdent().getEmailAddress()));
+    } finally {
+      setSignedOffByFooter(false);
+    }
   }
 
   @Test
@@ -378,7 +425,7 @@
     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.subject).isEqualTo(in.subject.split("\n")[0]);
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
     assertThat(out.isPrivate).isEqualTo(in.isPrivate);
@@ -398,17 +445,21 @@
   }
 
   // TODO(davido): Expose setting of account preferences in the API
-  private void setSignedOffByFooter() throws Exception {
+  private void setSignedOffByFooter(boolean value) throws Exception {
     RestResponse r = adminRestSession.get("/accounts/" + admin.email + "/preferences");
     r.assertOK();
     GeneralPreferencesInfo i = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
-    i.signedOffBy = true;
+    i.signedOffBy = value;
 
     r = adminRestSession.put("/accounts/" + admin.email + "/preferences", i);
     r.assertOK();
     GeneralPreferencesInfo o = newGson().fromJson(r.getReader(), GeneralPreferencesInfo.class);
 
-    assertThat(o.signedOffBy).isTrue();
+    if (value) {
+      assertThat(o.signedOffBy).isTrue();
+    } else {
+      assertThat(o.signedOffBy).isNull();
+    }
 
     resetCurrentApiUser();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 822841c..2958888 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -14,7 +14,21 @@
 
 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 com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+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 org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
 public class IndexChangeIT extends AbstractDaemonTest {
@@ -30,4 +44,62 @@
     blockRead("refs/heads/master");
     userRestSession.post("/changes/" + changeId + "/index/").assertNotFound();
   }
+
+  @Test
+  public void indexChangeAfterOwnerLosesVisibility() throws Exception {
+    // Create a test group with 2 users as members
+    TestAccount user2 = accountCreator.user2();
+    String group = createGroup("test");
+    gApi.groups().id(group).addMembers("admin", "user", user2.username);
+
+    // Create a project and restrict its visibility to the group
+    Project.NameKey p = createProject("p");
+    ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
+    Util.allow(
+        cfg,
+        Permission.READ,
+        groupCache.get(new AccountGroup.NameKey(group)).get().getGroupUUID(),
+        "refs/*");
+    Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
+    saveProjectConfig(p, cfg);
+
+    // Clone it and push a change as a regular user
+    TestRepository<InMemoryRepository> repo = cloneProject(p, user);
+    PushOneCommit push = pushFactory.create(db, user.getIdent(), repo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    assertThat(result.getChange().change().getOwner()).isEqualTo(user.id);
+    String changeId = result.getChangeId();
+
+    // User can see the change and it is mergeable
+    setApiUser(user);
+    List<ChangeInfo> changes = gApi.changes().query(changeId).get();
+    assertThat(changes).hasSize(1);
+    assertThat(changes.get(0).mergeable).isNotNull();
+
+    // Other user can see the change and it is mergeable
+    setApiUser(user2);
+    changes = gApi.changes().query(changeId).get();
+    assertThat(changes).hasSize(1);
+    assertThat(changes.get(0).mergeable).isTrue();
+
+    // Remove the user from the group so they can no longer see the project
+    setApiUser(admin);
+    gApi.groups().id(group).removeMembers("user");
+
+    // User can no longer see the change
+    setApiUser(user);
+    changes = gApi.changes().query(changeId).get();
+    assertThat(changes).isEmpty();
+
+    // Reindex the change
+    setApiUser(admin);
+    gApi.changes().id(changeId).index();
+
+    // Other user can still see the change and it is still mergeable
+    setApiUser(user2);
+    changes = gApi.changes().query(changeId).get();
+    assertThat(changes).hasSize(1);
+    assertThat(changes.get(0).mergeable).isTrue();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index c188d63..7657e2e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -85,6 +85,17 @@
   @Test
   public void suggestReviewersChange() throws Exception {
     String changeId = createChange().getChangeId();
+    testSuggestReviewersChange(changeId);
+  }
+
+  @Test
+  public void suggestReviewersPrivateChange() throws Exception {
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).setPrivate(true, null);
+    testSuggestReviewersChange(changeId);
+  }
+
+  public void testSuggestReviewersChange(String changeId) throws Exception {
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
     assertReviewers(
         reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group1, group2, group3));
@@ -133,7 +144,7 @@
   }
 
   @Test
-  public void suggestReviewsPrivateProjectVisibility() throws Exception {
+  public void suggestReviewersPrivateProjectVisibility() throws Exception {
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 8fc5312..206b06c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -56,7 +56,6 @@
   @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")
@@ -101,7 +100,6 @@
     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");
@@ -175,7 +173,6 @@
     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");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 532ce45..c8ad4a4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -16,6 +16,7 @@
 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 com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
@@ -33,6 +34,7 @@
 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.common.GroupInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
@@ -68,20 +70,18 @@
 
   private static final String LABEL_CODE_REVIEW = "Code-Review";
 
-  private String newProjectName;
-  private ProjectApi pApi;
+  private Project.NameKey newProjectName;
 
   @Inject private DynamicSet<FileHistoryWebLink> fileHistoryWebLinkDynamicSet;
 
   @Before
   public void setUp() throws Exception {
-    newProjectName = createProject(PROJECT_NAME).get();
-    pApi = gApi.projects().name(newProjectName);
+    newProjectName = createProject(PROJECT_NAME);
   }
 
   @Test
   public void getDefaultInheritance() throws Exception {
-    String inheritedName = pApi.access().inheritsFrom.name;
+    String inheritedName = pApi().access().inheritsFrom.name;
     assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
   }
 
@@ -98,7 +98,7 @@
               }
             });
     try {
-      ProjectAccessInfo info = pApi.access();
+      ProjectAccessInfo info = pApi().access();
       assertThat(info.configWebLinks).hasSize(1);
       assertThat(info.configWebLinks.get(0).url)
           .isEqualTo("http://view/" + newProjectName + "/project.config");
@@ -119,13 +119,13 @@
                     "name", "imageURL", "http://view/" + projectName + "/" + fileName);
               }
             });
-    try (Repository repo = repoManager.openRepository(new Project.NameKey(newProjectName))) {
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
       RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
       u.setForceUpdate(true);
       assertThat(u.delete()).isEqualTo(Result.FORCED);
 
       // This should not crash.
-      pApi.access();
+      pApi().access();
     } finally {
       handle.remove();
     }
@@ -133,34 +133,34 @@
 
   @Test
   public void addAccessSection() throws Exception {
-    Project.NameKey p = new Project.NameKey(newProjectName);
-    RevCommit initialHead = getRemoteHead(p, RefNames.REFS_CONFIG);
+    RevCommit initialHead = getRemoteHead(newProjectName, RefNames.REFS_CONFIG);
 
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
 
     accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi.access(accessInput);
+    pApi().access(accessInput);
 
-    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
 
-    RevCommit updatedHead = getRemoteHead(p, RefNames.REFS_CONFIG);
+    RevCommit updatedHead = getRemoteHead(newProjectName, RefNames.REFS_CONFIG);
     eventRecorder.assertRefUpdatedEvents(
-        p.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
+        newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
   }
 
   @Test
   public void createAccessChangeNop() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     exception.expect(BadRequestException.class);
-    pApi.accessChange(accessInput);
+    pApi().accessChange(accessInput);
   }
 
   @Test
   public void createAccessChange() throws Exception {
+    allow(newProjectName, RefNames.REFS_CONFIG, Permission.READ, REGISTERED_USERS);
     // User can see the branch
     setApiUser(user);
-    gApi.projects().name(newProjectName).branch("refs/heads/master").get();
+    pApi().branch("refs/heads/master").get();
 
     ProjectAccessInput accessInput = newProjectAccessInput();
 
@@ -175,9 +175,9 @@
     accessInput.add.put(REFS_HEADS, accessSection);
 
     setApiUser(user);
-    ChangeInfo out = pApi.accessChange(accessInput);
+    ChangeInfo out = pApi().accessChange(accessInput);
 
-    assertThat(out.project).isEqualTo(newProjectName);
+    assertThat(out.project).isEqualTo(newProjectName.get());
     assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
     assertThat(out.status).isEqualTo(ChangeStatus.NEW);
     assertThat(out.submitted).isNull();
@@ -195,7 +195,7 @@
     // check that the change took effect.
     setApiUser(user);
     try {
-      BranchInfo info = gApi.projects().name(newProjectName).branch("refs/heads/master").get();
+      BranchInfo info = pApi().branch("refs/heads/master").get();
       fail("wanted failure, got " + newGson().toJson(info));
     } catch (ResourceNotFoundException e) {
       // OK.
@@ -206,17 +206,15 @@
     accessInput.remove.put(REFS_HEADS, accessSection);
     setApiUser(user);
 
-    pApi.accessChange(accessInput);
-
     setApiUser(admin);
-    out = pApi.accessChange(accessInput);
+    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();
+    pApi().branch("refs/heads/master").get();
   }
 
   @Test
@@ -226,7 +224,7 @@
     AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
 
     accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi.access(accessInput);
+    pApi().access(accessInput);
 
     // Remove specific permission
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
@@ -234,13 +232,13 @@
         Permission.LABEL + LABEL_CODE_REVIEW, newPermissionInfo());
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi.access(removal);
+    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);
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
   }
 
   @Test
@@ -250,7 +248,7 @@
     AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
 
     accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi.access(accessInput);
+    pApi().access(accessInput);
 
     // Remove specific permission rule
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
@@ -261,7 +259,7 @@
     accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi.access(removal);
+    pApi().access(removal);
 
     // Remove locally
     accessInput
@@ -273,7 +271,7 @@
         .remove(SystemGroupBackend.REGISTERED_USERS.get());
 
     // Check
-    assertThat(pApi.access().local).isEqualTo(accessInput.add);
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
   }
 
   @Test
@@ -283,7 +281,7 @@
     AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
 
     accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi.access(accessInput);
+    pApi().access(accessInput);
 
     // Remove specific permission rules
     AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
@@ -296,13 +294,13 @@
     accessSectionToRemove.permissions.put(Permission.LABEL + LABEL_CODE_REVIEW, codeReview);
     ProjectAccessInput removal = newProjectAccessInput();
     removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi.access(removal);
+    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);
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
   }
 
   @Test
@@ -313,11 +311,11 @@
 
     // Disallow READ
     accessInput.add.put(REFS_ALL, accessSectionInfo);
-    pApi.access(accessInput);
+    pApi().access(accessInput);
 
     setApiUser(user);
     exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(newProjectName).access();
+    pApi().access();
   }
 
   @Test
@@ -328,7 +326,7 @@
 
     // Disallow READ
     accessInput.add.put(REFS_ALL, accessSectionInfo);
-    pApi.access(accessInput);
+    pApi().access(accessInput);
 
     // Create a change to apply
     ProjectAccessInput accessInfoToApply = newProjectAccessInput();
@@ -337,7 +335,7 @@
 
     setApiUser(user);
     exception.expect(ResourceNotFoundException.class);
-    gApi.projects().name(newProjectName).access();
+    pApi().access();
   }
 
   @Test
@@ -357,7 +355,7 @@
     accessSection.permissions.put(Permission.READ, read);
 
     accessInput.add.put(REFS_ALL, accessSection);
-    ProjectAccessInfo result = pApi.access(accessInput);
+    ProjectAccessInfo result = pApi().access(accessInput);
     assertThat(result.groups.keySet())
         .containsExactly(
             SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
@@ -370,19 +368,23 @@
     assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
 
     // Get call returns groups too.
-    ProjectAccessInfo loggedInResult = pApi.access();
+    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.
+    GroupInfo owners = loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get());
+    assertThat(owners.name).isEqualTo("Project Owners");
+    assertThat(owners.id).isNull();
+    assertThat(owners.members).isNull();
+    assertThat(owners.includes).isNull();
+
+    // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
     setApiUserAnonymous();
-    ProjectAccessInfo anonResult = pApi.access();
+    ProjectAccessInfo anonResult = pApi().access();
     assertThat(anonResult.groups.keySet())
-        .containsExactly(SystemGroupBackend.ANONYMOUS_USERS.get());
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
   }
 
   @Test
@@ -397,7 +399,7 @@
     setApiUser(user);
     exception.expect(AuthException.class);
     exception.expectMessage("administrate server not permitted");
-    gApi.projects().name(newProjectName).access(accessInput);
+    pApi().access(accessInput);
   }
 
   @Test
@@ -409,9 +411,9 @@
     ProjectAccessInput accessInput = newProjectAccessInput();
     accessInput.parent = newParentProjectName;
 
-    gApi.projects().name(newProjectName).access(accessInput);
+    pApi().access(accessInput);
 
-    assertThat(pApi.access().inheritsFrom.name).isEqualTo(newParentProjectName);
+    assertThat(pApi().access().inheritsFrom.name).isEqualTo(newParentProjectName);
   }
 
   @Test
@@ -452,7 +454,7 @@
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
 
     exception.expect(BadRequestException.class);
-    pApi.access(accessInput);
+    pApi().access(accessInput);
   }
 
   @Test
@@ -627,6 +629,10 @@
     assertThat(permissions2.keySet()).containsExactly(Permission.READ);
   }
 
+  private ProjectApi pApi() throws Exception {
+    return gApi.projects().name(newProjectName.get());
+  }
+
   private ProjectAccessInput newProjectAccessInput() {
     ProjectAccessInput p = new ProjectAccessInput();
     p.add = new HashMap<>();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index a96c6ec..df4076d 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -68,6 +68,9 @@
     // Check that the comments from the email have NOT been persisted
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
     assertThat(messages).hasSize(2);
+
+    // Check that no emails were sent because of this error
+    assertThat(sender.getMessages()).isEmpty();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 9de4797..f34fe33 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.mail.MailUtil;
 import com.google.gerrit.server.mail.receive.MailMessage;
 import com.google.gerrit.server.mail.receive.MailProcessor;
+import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
@@ -226,4 +227,34 @@
 
     assertNotifyTo(admin);
   }
+
+  @Test
+  public void sendNotificationOnMissingMetadatas() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(2);
+    String ts = "null"; // Erroneous timestamp to be used in erroneous metadatas
+
+    // Build Message
+    String txt =
+        newPlaintextBody(
+            canonicalWebUrl.get() + "#/c/" + changeInfo._number + "/1",
+            "Test Message",
+            null,
+            null,
+            null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.emailAddress)
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("was unable to parse your email");
+    assertThat(message.headers()).containsKey("Subject");
+  }
 }
diff --git a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
index fc0b1dcd..231b584 100644
--- a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
+++ b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
@@ -20,7 +20,6 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.extensions.client.Theme;
 import java.util.List;
@@ -142,49 +141,49 @@
 
   @Test
   public void timeUnit() {
-    assertEquals(ms(0, MILLISECONDS), parse("0"));
-    assertEquals(ms(2, MILLISECONDS), parse("2ms"));
-    assertEquals(ms(200, MILLISECONDS), parse("200 milliseconds"));
+    assertThat(parse("0")).isEqualTo(ms(0, MILLISECONDS));
+    assertThat(parse("2ms")).isEqualTo(ms(2, MILLISECONDS));
+    assertThat(parse("200 milliseconds")).isEqualTo(ms(200, MILLISECONDS));
 
-    assertEquals(ms(0, SECONDS), parse("0s"));
-    assertEquals(ms(2, SECONDS), parse("2s"));
-    assertEquals(ms(231, SECONDS), parse("231sec"));
-    assertEquals(ms(1, SECONDS), parse("1second"));
-    assertEquals(ms(300, SECONDS), parse("300 seconds"));
+    assertThat(parse("0s")).isEqualTo(ms(0, SECONDS));
+    assertThat(parse("2s")).isEqualTo(ms(2, SECONDS));
+    assertThat(parse("231sec")).isEqualTo(ms(231, SECONDS));
+    assertThat(parse("1second")).isEqualTo(ms(1, SECONDS));
+    assertThat(parse("300 seconds")).isEqualTo(ms(300, SECONDS));
 
-    assertEquals(ms(2, MINUTES), parse("2m"));
-    assertEquals(ms(2, MINUTES), parse("2min"));
-    assertEquals(ms(1, MINUTES), parse("1 minute"));
-    assertEquals(ms(10, MINUTES), parse("10 minutes"));
+    assertThat(parse("2m")).isEqualTo(ms(2, MINUTES));
+    assertThat(parse("2min")).isEqualTo(ms(2, MINUTES));
+    assertThat(parse("1 minute")).isEqualTo(ms(1, MINUTES));
+    assertThat(parse("10 minutes")).isEqualTo(ms(10, MINUTES));
 
-    assertEquals(ms(5, HOURS), parse("5h"));
-    assertEquals(ms(5, HOURS), parse("5hr"));
-    assertEquals(ms(1, HOURS), parse("1hour"));
-    assertEquals(ms(48, HOURS), parse("48hours"));
+    assertThat(parse("5h")).isEqualTo(ms(5, HOURS));
+    assertThat(parse("5hr")).isEqualTo(ms(5, HOURS));
+    assertThat(parse("1hour")).isEqualTo(ms(1, HOURS));
+    assertThat(parse("48hours")).isEqualTo(ms(48, HOURS));
 
-    assertEquals(ms(5, HOURS), parse("5 h"));
-    assertEquals(ms(5, HOURS), parse("5 hr"));
-    assertEquals(ms(1, HOURS), parse("1 hour"));
-    assertEquals(ms(48, HOURS), parse("48 hours"));
-    assertEquals(ms(48, HOURS), parse("48 \t \r hours"));
+    assertThat(parse("5 h")).isEqualTo(ms(5, HOURS));
+    assertThat(parse("5 hr")).isEqualTo(ms(5, HOURS));
+    assertThat(parse("1 hour")).isEqualTo(ms(1, HOURS));
+    assertThat(parse("48 hours")).isEqualTo(ms(48, HOURS));
+    assertThat(parse("48 \t \r hours")).isEqualTo(ms(48, HOURS));
 
-    assertEquals(ms(4, DAYS), parse("4d"));
-    assertEquals(ms(1, DAYS), parse("1day"));
-    assertEquals(ms(14, DAYS), parse("14days"));
+    assertThat(parse("4d")).isEqualTo(ms(4, DAYS));
+    assertThat(parse("1day")).isEqualTo(ms(1, DAYS));
+    assertThat(parse("14days")).isEqualTo(ms(14, DAYS));
 
-    assertEquals(ms(7, DAYS), parse("1w"));
-    assertEquals(ms(7, DAYS), parse("1week"));
-    assertEquals(ms(14, DAYS), parse("2w"));
-    assertEquals(ms(14, DAYS), parse("2weeks"));
+    assertThat(parse("1w")).isEqualTo(ms(7, DAYS));
+    assertThat(parse("1week")).isEqualTo(ms(7, DAYS));
+    assertThat(parse("2w")).isEqualTo(ms(14, DAYS));
+    assertThat(parse("2weeks")).isEqualTo(ms(14, DAYS));
 
-    assertEquals(ms(30, DAYS), parse("1mon"));
-    assertEquals(ms(30, DAYS), parse("1month"));
-    assertEquals(ms(60, DAYS), parse("2mon"));
-    assertEquals(ms(60, DAYS), parse("2months"));
+    assertThat(parse("1mon")).isEqualTo(ms(30, DAYS));
+    assertThat(parse("1month")).isEqualTo(ms(30, DAYS));
+    assertThat(parse("2mon")).isEqualTo(ms(60, DAYS));
+    assertThat(parse("2months")).isEqualTo(ms(60, DAYS));
 
-    assertEquals(ms(365, DAYS), parse("1y"));
-    assertEquals(ms(365, DAYS), parse("1year"));
-    assertEquals(ms(365 * 2, DAYS), parse("2years"));
+    assertThat(parse("1y")).isEqualTo(ms(365, DAYS));
+    assertThat(parse("1year")).isEqualTo(ms(365, DAYS));
+    assertThat(parse("2years")).isEqualTo(ms(365 * 2, DAYS));
   }
 
   private static long ms(int cnt, TimeUnit unit) {
diff --git a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
index ab7da99..cb6de34 100644
--- a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
@@ -14,27 +14,29 @@
 
 package com.google.gerrit.server.config;
 
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import org.junit.Test;
 
 public class GitwebConfigTest {
-
   private static final String VALID_CHARACTERS = "*()";
   private static final String SOME_INVALID_CHARACTERS = "09AZaz$-_.+!',";
 
   @Test
   public void validPathSeparator() {
     for (char c : VALID_CHARACTERS.toCharArray()) {
-      assertTrue("valid character rejected: " + c, GitwebConfig.isValidPathSeparator(c));
+      assertWithMessage("valid character rejected: " + c)
+          .that(GitwebConfig.isValidPathSeparator(c))
+          .isTrue();
     }
   }
 
   @Test
   public void inalidPathSeparator() {
     for (char c : SOME_INVALID_CHARACTERS.toCharArray()) {
-      assertFalse("invalid character accepted: " + c, GitwebConfig.isValidPathSeparator(c));
+      assertWithMessage("invalid character accepted: " + c)
+          .that(GitwebConfig.isValidPathSeparator(c))
+          .isFalse();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
index bcba665..577d931 100644
--- a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
+++ b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
@@ -14,9 +14,7 @@
 
 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 static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -60,14 +58,14 @@
     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);
+      assertThat(m).containsKey(id);
+      assertThat(m.get(id).id).isEqualTo(id);
+      assertThat(m.get(id).name).isNotNull();
     }
 
     String pluginCapability = "gerrit-printHello";
-    assertTrue("contains " + pluginCapability, m.containsKey(pluginCapability));
-    assertEquals(pluginCapability, m.get(pluginCapability).id);
-    assertEquals("Print Hello", m.get(pluginCapability).name);
+    assertThat(m).containsKey(pluginCapability);
+    assertThat(m.get(pluginCapability).id).isEqualTo(pluginCapability);
+    assertThat(m.get(pluginCapability).name).isEqualTo("Print Hello");
   }
 }
diff --git a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
index 0423a53..70893a9 100644
--- a/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/ScheduleConfigTest.java
@@ -14,16 +14,19 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 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 com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
@@ -36,45 +39,186 @@
 
   @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"));
+    assertThat(initialDelay("11:00", "1h")).isEqualTo(ms(1, HOURS));
+    assertThat(initialDelay("05:30", "1h")).isEqualTo(ms(30, MINUTES));
+    assertThat(initialDelay("09:30", "1h")).isEqualTo(ms(30, MINUTES));
+    assertThat(initialDelay("13:30", "1h")).isEqualTo(ms(30, MINUTES));
+    assertThat(initialDelay("13:59", "1h")).isEqualTo(ms(59, MINUTES));
 
-    assertEquals(ms(1, HOURS), initialDelay("11:00", "1d"));
-    assertEquals(ms(19, HOURS) + ms(30, MINUTES), initialDelay("05:30", "1d"));
+    assertThat(initialDelay("11:00", "1d")).isEqualTo(ms(1, HOURS));
+    assertThat(initialDelay("05:30", "1d")).isEqualTo(ms(19, HOURS) + ms(30, MINUTES));
 
-    assertEquals(ms(1, HOURS), initialDelay("11:00", "1w"));
-    assertEquals(ms(7, DAYS) - ms(4, HOURS) - ms(30, MINUTES), initialDelay("05:30", "1w"));
+    assertThat(initialDelay("11:00", "1w")).isEqualTo(ms(1, HOURS));
+    assertThat(initialDelay("05:30", "1w")).isEqualTo(ms(7, DAYS) - ms(4, HOURS) - ms(30, MINUTES));
 
-    assertEquals(ms(3, DAYS) + ms(1, HOURS), initialDelay("Mon 11:00", "1w"));
-    assertEquals(ms(1, HOURS), initialDelay("Fri 11:00", "1w"));
+    assertThat(initialDelay("Mon 11:00", "1w")).isEqualTo(ms(3, DAYS) + ms(1, HOURS));
+    assertThat(initialDelay("Fri 11:00", "1w")).isEqualTo(ms(1, HOURS));
 
-    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"));
+    assertThat(initialDelay("Mon 11:00", "1d")).isEqualTo(ms(1, HOURS));
+    assertThat(initialDelay("Mon 09:00", "1d")).isEqualTo(ms(23, HOURS));
+    assertThat(initialDelay("Mon 10:00", "1d")).isEqualTo(ms(1, DAYS));
+    assertThat(initialDelay("Mon 10:00", "1d")).isEqualTo(ms(1, DAYS));
   }
 
   @Test
-  public void customKeys() {
+  public void defaultKeysWithoutSubsection() {
+    Config rc = new Config();
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "1h");
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "01:00");
+
+    assertThat(ScheduleConfig.builder(rc, "a").setNow(NOW).buildSchedule())
+        .hasValue(Schedule.create(ms(1, HOURS), ms(1, HOURS)));
+  }
+
+  @Test
+  public void defaultKeysWithSubsection() {
+    Config rc = new Config();
+    rc.setString("a", "b", ScheduleConfig.KEY_INTERVAL, "1h");
+    rc.setString("a", "b", ScheduleConfig.KEY_STARTTIME, "01:00");
+
+    assertThat(ScheduleConfig.builder(rc, "a").setSubsection("b").setNow(NOW).buildSchedule())
+        .hasValue(Schedule.create(ms(1, HOURS), ms(1, HOURS)));
+  }
+
+  @Test
+  public void customKeysWithoutSubsection() {
+    Config rc = new Config();
+    rc.setString("a", null, "i", "1h");
+    rc.setString("a", null, "s", "01:00");
+
+    assertThat(
+            ScheduleConfig.builder(rc, "a")
+                .setKeyInterval("i")
+                .setKeyStartTime("s")
+                .setNow(NOW)
+                .buildSchedule())
+        .hasValue(Schedule.create(ms(1, HOURS), ms(1, HOURS)));
+  }
+
+  @Test
+  public void customKeysWithSubsection() {
     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());
+    assertThat(
+            ScheduleConfig.builder(rc, "a")
+                .setSubsection("b")
+                .setKeyInterval("i")
+                .setKeyStartTime("s")
+                .setNow(NOW)
+                .buildSchedule())
+        .hasValue(Schedule.create(ms(1, HOURS), ms(1, HOURS)));
+  }
 
-    s = new ScheduleConfig(rc, "a", "b", "myInterval", "myStart", NOW);
-    assertEquals(s.getInterval(), ScheduleConfig.MISSING_CONFIG);
-    assertEquals(s.getInitialDelay(), ScheduleConfig.MISSING_CONFIG);
+  @Test
+  public void missingConfigWithoutSubsection() {
+    Config rc = new Config();
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "1h");
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "01:00");
+
+    assertThat(
+            ScheduleConfig.builder(rc, "a")
+                .setKeyInterval("myInterval")
+                .setKeyStartTime("myStart")
+                .buildSchedule())
+        .isEmpty();
+
+    assertThat(ScheduleConfig.builder(rc, "x").buildSchedule()).isEmpty();
+  }
+
+  @Test
+  public void missingConfigWithSubsection() {
+    Config rc = new Config();
+    rc.setString("a", "b", ScheduleConfig.KEY_INTERVAL, "1h");
+    rc.setString("a", "b", ScheduleConfig.KEY_STARTTIME, "01:00");
+
+    assertThat(
+            ScheduleConfig.builder(rc, "a")
+                .setSubsection("b")
+                .setKeyInterval("myInterval")
+                .setKeyStartTime("myStart")
+                .buildSchedule())
+        .isEmpty();
+
+    assertThat(ScheduleConfig.builder(rc, "a").setSubsection("x").buildSchedule()).isEmpty();
+
+    assertThat(ScheduleConfig.builder(rc, "x").setSubsection("b").buildSchedule()).isEmpty();
+  }
+
+  @Test
+  public void incompleteConfigMissingInterval() {
+    Config rc = new Config();
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "01:00");
+
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+  }
+
+  @Test
+  public void incompleteConfigMissingStartTime() {
+    Config rc = new Config();
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "1h");
+
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+  }
+
+  @Test
+  public void invalidConfigBadInterval() {
+    Config rc = new Config();
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "01:00");
+
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "x");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "1x");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "0");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "-1");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+  }
+
+  @Test
+  public void invalidConfigBadStartTime() {
+    Config rc = new Config();
+    rc.setString("a", null, ScheduleConfig.KEY_INTERVAL, "1h");
+
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "x");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "Foo 01:00");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "Mon 01:000");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "001:00");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+
+    rc.setString("a", null, ScheduleConfig.KEY_STARTTIME, "0100");
+    assertThat(ScheduleConfig.builder(rc, "a").buildSchedule()).isEmpty();
+  }
+
+  @Test
+  public void createInvalidSchedule() {
+    assertThat(Schedule.create(-1, "00:00")).isEmpty();
+    assertThat(Schedule.create(1, "x")).isEmpty();
+    assertThat(Schedule.create(1, "Foo 00:00")).isEmpty();
+    assertThat(Schedule.create(0, "Mon 00:000")).isEmpty();
+    assertThat(Schedule.create(1, "000:00")).isEmpty();
+    assertThat(Schedule.create(1, "0000")).isEmpty();
   }
 
   private static long initialDelay(String startTime, String interval) {
-    return new ScheduleConfig(config(startTime, interval), "section", "subsection", NOW)
-        .getInitialDelay();
+    Optional<Schedule> schedule =
+        ScheduleConfig.builder(config(startTime, interval), "section")
+            .setSubsection("subsection")
+            .setNow(NOW)
+            .buildSchedule();
+    assertThat(schedule).isPresent();
+    return schedule.get().initialDelay();
   }
 
   private static Config config(String startTime, String interval) {
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index c3d9ba5..5772a80 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -25,12 +25,16 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
 import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendCondition;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
 import java.util.Collection;
+import java.util.Map;
 import java.util.Set;
 import org.easymock.EasyMock;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
 public class UiActionsTest {
@@ -85,6 +89,12 @@
       return ImmutableSet.of(ProjectPermission.READ);
     }
 
+    @Override
+    public Map<String, Ref> filter(Map<String, Ref> refs, Repository repo, RefFilterOptions opts)
+        throws PermissionBackendException {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
     private void disallowValueQueries() {
       allowValueQueries = false;
     }
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 5ecafd0..031284d 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -24,6 +24,7 @@
 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.Change;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.testing.GerritBaseTests;
@@ -60,7 +61,7 @@
         .containsExactly(
             "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
 
-    assertThat(ChangeField.parseReviewerFieldValues(values)).isEqualTo(reviewers);
+    assertThat(ChangeField.parseReviewerFieldValues(new Change.Id(1), values)).isEqualTo(reviewers);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index b525504..edd4abf 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -27,7 +27,7 @@
         new FakeQueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, null, indexes, null, null, null, null, null, null, null, null));
+            null, null, null, null, null, indexes, null, null, null, null, null, null, null));
   }
 
   @Operator
diff --git a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
new file mode 100644
index 0000000..a7234f4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
@@ -0,0 +1,71 @@
+// 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.mail;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.mail.receive.MailMessage;
+import com.google.gerrit.testing.GerritBaseTests;
+import java.time.Instant;
+import org.junit.Test;
+
+public class AutoReplyMailFilterTest extends GerritBaseTests {
+
+  private AutoReplyMailFilter autoReplyMailFilter = new AutoReplyMailFilter();
+
+  @Test
+  public void acceptsHumanReply() {
+    MailMessage.Builder b = createChangeAndReplyByEmail();
+    assertThat(autoReplyMailFilter.shouldProcessMessage(b.build())).isTrue();
+  }
+
+  @Test
+  public void discardsBulk() {
+    MailMessage.Builder b = createChangeAndReplyByEmail();
+    b.addAdditionalHeader("Precedence: bulk");
+    assertThat(autoReplyMailFilter.shouldProcessMessage(b.build())).isFalse();
+
+    b = createChangeAndReplyByEmail();
+    b.addAdditionalHeader("Precedence: list");
+    assertThat(autoReplyMailFilter.shouldProcessMessage(b.build())).isFalse();
+
+    b = createChangeAndReplyByEmail();
+    b.addAdditionalHeader("Precedence: junk");
+    assertThat(autoReplyMailFilter.shouldProcessMessage(b.build())).isFalse();
+  }
+
+  @Test
+  public void discardsAutoSubmitted() {
+    MailMessage.Builder b = createChangeAndReplyByEmail();
+    b.addAdditionalHeader("Auto-Submitted: yes");
+    assertThat(autoReplyMailFilter.shouldProcessMessage(b.build())).isFalse();
+
+    b = createChangeAndReplyByEmail();
+    b.addAdditionalHeader("Auto-Submitted: no");
+    assertThat(autoReplyMailFilter.shouldProcessMessage(b.build())).isTrue();
+  }
+
+  private MailMessage.Builder createChangeAndReplyByEmail() {
+    // Build Message
+    MailMessage.Builder b = MailMessage.builder();
+    b.id("some id");
+    b.from(new Address("admim@example.com"));
+    b.addTo(new Address("gerrit@my-company.com")); // Not evaluated
+    b.subject("");
+    b.dateReceived(Instant.now());
+    b.textContent("I am currently out of office, please leave a code review after the beep.");
+    return b;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java b/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java
index b9548bd..d88e09f 100644
--- a/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java
+++ b/javatests/com/google/gerrit/server/mail/receive/HtmlParserTest.java
@@ -83,6 +83,32 @@
   }
 
   @Test
+  public void simpleInlineCommentsWithLink() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(
+        newHtmlBody(
+            "Looks good to me",
+            "How about [1]? This would help IMHO.</div><div>[1] "
+                + "<a href=\"http://gerritcodereview.com\">http://gerritcodereview.com</a>",
+            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(
+        "How about [1]? This would help IMHO.\n\n[1] http://gerritcodereview.com",
+        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(
diff --git a/javatests/com/google/gerrit/server/mail/receive/MetadataParserTest.java b/javatests/com/google/gerrit/server/mail/receive/MailHeaderParserTest.java
similarity index 71%
rename from javatests/com/google/gerrit/server/mail/receive/MetadataParserTest.java
rename to javatests/com/google/gerrit/server/mail/receive/MailHeaderParserTest.java
index dc25939..b7277f3 100644
--- a/javatests/com/google/gerrit/server/mail/receive/MetadataParserTest.java
+++ b/javatests/com/google/gerrit/server/mail/receive/MailHeaderParserTest.java
@@ -15,18 +15,16 @@
 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 com.google.gerrit.server.mail.MailHeader;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneOffset;
 import org.junit.Test;
 
-public class MetadataParserTest {
+public class MailHeaderParserTest {
   @Test
   public void parseMetadataFromHeader() {
     // This tests if the metadata parser is able to parse metadata from the
@@ -36,16 +34,16 @@
     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(MailHeader.CHANGE_NUMBER.fieldWithDelimiter() + "123");
+    b.addAdditionalHeader(MailHeader.PATCH_SET.fieldWithDelimiter() + "1");
+    b.addAdditionalHeader(MailHeader.MESSAGE_TYPE.fieldWithDelimiter() + "comment");
     b.addAdditionalHeader(
-        toHeaderWithDelimiter(MetadataName.TIMESTAMP) + "Tue, 25 Oct 2016 02:11:35 -0700");
+        MailHeader.COMMENT_DATE.fieldWithDelimiter() + "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());
+    MailMetadata meta = MailHeaderParser.parse(b.build());
     assertThat(meta.author).isEqualTo(author.getEmail());
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
@@ -67,17 +65,17 @@
     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(MailHeader.CHANGE_NUMBER.withDelimiter() + "123\r\n");
+    stringBuilder.append("> " + MailHeader.PATCH_SET.withDelimiter() + "1\n");
+    stringBuilder.append(MailHeader.MESSAGE_TYPE.withDelimiter() + "comment\n");
     stringBuilder.append(
-        toFooterWithDelimiter(MetadataName.TIMESTAMP) + "Tue, 25 Oct 2016 02:11:35 -0700\r\n");
+        MailHeader.COMMENT_DATE.withDelimiter() + "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());
+    MailMetadata meta = MailHeaderParser.parse(b.build());
     assertThat(meta.author).isEqualTo(author.getEmail());
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
@@ -100,13 +98,12 @@
 
     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>");
+        "<div id\"someid\">" + MailHeader.CHANGE_NUMBER.withDelimiter() + "123</div>");
+    stringBuilder.append("<div>" + MailHeader.PATCH_SET.withDelimiter() + "1</div>");
+    stringBuilder.append("<div>" + MailHeader.MESSAGE_TYPE.withDelimiter() + "comment</div>");
     stringBuilder.append(
         "<div>"
-            + toFooterWithDelimiter(MetadataName.TIMESTAMP)
+            + MailHeader.COMMENT_DATE.withDelimiter()
             + "Tue, 25 Oct 2016 02:11:35 -0700"
             + "</div>");
     b.htmlContent(stringBuilder.toString());
@@ -114,7 +111,7 @@
     Address author = new Address("Diffy", "test@gerritcodereview.com");
     b.from(author);
 
-    MailMetadata meta = MetadataParser.parse(b.build());
+    MailMetadata meta = MailHeaderParser.parse(b.build());
     assertThat(meta.author).isEqualTo(author.getEmail());
     assertThat(meta.changeNumber).isEqualTo(123);
     assertThat(meta.patchSet).isEqualTo(1);
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 0bccf8b..7295f37 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -27,6 +27,7 @@
 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.allowExclusive;
 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;
@@ -201,6 +202,7 @@
   @Inject private SingleVersionListener singleVersionListener;
   @Inject private InMemoryDatabase schemaFactory;
   @Inject private ThreadLocalRequestContext requestContext;
+  @Inject private DefaultRefFilter.Factory refFilterFactory;
 
   @Before
   public void setUp() throws Exception {
@@ -634,6 +636,16 @@
   }
 
   @Test
+  public void unblockRead_NotPossible() {
+    block(parent, READ, ANONYMOUS_USERS, "refs/*");
+    allow(parent, READ, ADMIN, "refs/*");
+    allow(local, READ, ANONYMOUS_USERS, "refs/*");
+    allow(local, READ, ADMIN, "refs/*");
+    ProjectControl u = user(local);
+    assertCannotRead("refs/heads/master", u);
+  }
+
+  @Test
   public void unblockForceWithAllowNoForce_NotPossible() {
     PermissionRule r = block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     r.setForce(true);
@@ -671,6 +683,36 @@
   }
 
   @Test
+  public void unblockVoteMoreSpecificRefWithExclusiveFlag() {
+    String perm = LABEL + "Code-Review";
+
+    block(local, perm, -1, 1, ANONYMOUS_USERS, "refs/heads/*");
+    allowExclusive(local, perm, -2, 2, DEVS, "refs/heads/master");
+
+    ProjectControl u = user(local, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(perm);
+    assertCanVote(-2, range);
+  }
+
+  @Test
+  public void unblockFromParentDoesNotAffectChild() {
+    allow(parent, PUSH, DEVS, "refs/heads/master", true);
+    block(local, PUSH, DEVS, "refs/heads/master");
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
+  public void unblockFromParentDoesNotAffectChildDifferentGroups() {
+    allow(parent, PUSH, DEVS, "refs/heads/master", true);
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/master");
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/master", u);
+  }
+
+  @Test
   public void unblockMoreSpecificRefInLocalWithExclusiveFlag_Fails() {
     block(parent, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, PUSH, DEVS, "refs/heads/master", true);
@@ -680,6 +722,16 @@
   }
 
   @Test
+  public void blockMoreSpecificRefWithinProject() {
+    block(local, PUSH, ANONYMOUS_USERS, "refs/heads/secret");
+    allow(local, PUSH, DEVS, "refs/heads/*", true);
+
+    ProjectControl u = user(local, DEVS);
+    assertCannotUpdate("refs/heads/secret", u);
+    assertCanUpdate("refs/heads/master", u);
+  }
+
+  @Test
   public void unblockOtherPermissionWithMoreSpecificRefAndExclusiveFlag_Fails() {
     block(local, PUSH, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, PUSH, DEVS, "refs/heads/master");
@@ -847,6 +899,18 @@
   }
 
   @Test
+  public void unionOfBlockedVotes() {
+    allow(parent, LABEL + "Code-Review", -1, +1, DEVS, "refs/heads/*");
+    block(parent, LABEL + "Code-Review", -2, +2, REGISTERED_USERS, "refs/heads/*");
+    block(local, LABEL + "Code-Review", -2, +1, REGISTERED_USERS, "refs/heads/*");
+
+    ProjectControl u = user(local, DEVS);
+    PermissionRange range = u.controlForRef("refs/heads/master").getRange(LABEL + "Code-Review");
+    assertCanVote(-1, range);
+    assertCannotVote(1, range);
+  }
+
+  @Test
   public void blockOwner() {
     block(parent, OWNER, ANONYMOUS_USERS, "refs/*");
     allow(local, OWNER, DEVS, "refs/*");
@@ -919,6 +983,7 @@
         sectionSorter,
         changeControlFactory,
         permissionBackend,
+        refFilterFactory,
         new MockUser(name, memberOf),
         newProjectState(local));
   }
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index 267c622..e3aec45 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -19,118 +19,61 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
+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.GroupReference;
 import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.common.data.PermissionRule;
 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.gerrit.testing.InMemoryTestEnvironment;
 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.Rule;
 import org.junit.Test;
 
 /** Unit tests for {@link CommitsCollection}. */
 public class CommitsCollectionTest {
+  @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
+
   @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);
+    Account.Id user = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    testEnvironment.setApiUser(user);
 
     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
@@ -256,6 +199,8 @@
   }
 
   private void setUpPermissions() throws Exception {
+    ImmutableList<AccountGroup.UUID> admins = getAdmins();
+
     // 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.
@@ -263,6 +208,24 @@
     for (AccessSection sec : pc.getAccessSections()) {
       sec.removePermission(Permission.READ);
     }
-    allow(pc, Permission.READ, admins, "refs/*");
+    for (AccountGroup.UUID admin : admins) {
+      allow(pc, Permission.READ, admin, "refs/*");
+    }
+  }
+
+  private ImmutableList<AccountGroup.UUID> getAdmins() {
+    Permission adminPermission =
+        projectCache
+            .getAllProjects()
+            .getConfig()
+            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+            .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
+
+    return adminPermission
+        .getRules()
+        .stream()
+        .map(PermissionRule::getGroup)
+        .map(GroupReference::getUUID)
+        .collect(ImmutableList.toImmutableList());
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 1c124d2..2bff3f9 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -16,8 +16,8 @@
 
 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 java.util.stream.Collectors.toList;
-import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -27,7 +27,6 @@
 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;
@@ -263,12 +262,7 @@
 
   @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;
-    }
+    assume().that(getSchemaVersion() >= 4).isTrue();
 
     AccountInfo user1 = createAccount("user1", "User1", "user1@example.com");
     AccountInfo user2 = createAccount("user2", "User2", "user2@example.com");
@@ -288,12 +282,7 @@
 
   @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;
-    }
+    assume().that(getSchemaVersion() >= 4).isTrue();
 
     GroupInfo superParentGroup = createGroup(name("superParentGroup"));
     GroupInfo parentGroup1 = createGroup(name("parentGroup1"));
@@ -549,21 +538,6 @@
     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();
   }
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 2b235a1..67672d3 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -17,98 +17,37 @@
 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.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.testing.InMemoryTestEnvironment;
 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.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 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;
+  @Rule public InMemoryTestEnvironment testEnvironment = new InMemoryTestEnvironment();
+
+  @Inject private GitRepositoryManager repoManager;
   @Inject private BatchUpdate.Factory batchUpdateFactory;
+  @Inject private ReviewDb db;
+  @Inject private Provider<CurrentUser> user;
 
-  // 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;
+  private TestRepository<Repository> repo;
 
   @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);
+    Repository 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
@@ -116,7 +55,7 @@
     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())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(db, project, user.get(), TimeUtil.nowTs())) {
       bu.addRepoOnlyOp(
           new RepoOnlyOp() {
             @Override
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
index c035793..5ee3535 100644
--- a/lib/js/bower_archives.bzl
+++ b/lib/js/bower_archives.bzl
@@ -25,8 +25,8 @@
   bower_archive(
     name = "font-roboto",
     package = "PolymerElements/font-roboto",
-    version = "1.0.3",
-    sha1 = "edf478d20ae2fc0704d7c155e20162caaabdd5ae")
+    version = "1.1.0",
+    sha1 = "ab4218d87b9ce569d6282b01f7642e551879c3d5")
   bower_archive(
     name = "iron-a11y-announcer",
     package = "PolymerElements/iron-a11y-announcer",
@@ -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(
@@ -55,8 +55,8 @@
   bower_archive(
     name = "iron-flex-layout",
     package = "PolymerElements/iron-flex-layout",
-    version = "1.3.7",
-    sha1 = "4d4cf3232cf750a17a7df0a37476117f831ac633")
+    version = "1.3.9",
+    sha1 = "d987b924cf29fcfe4b393833e81fdc9f1e268796")
   bower_archive(
     name = "iron-form-element-behavior",
     package = "PolymerElements/iron-form-element-behavior",
@@ -103,6 +103,11 @@
     version = "1.0.13",
     sha1 = "a81eab28a952e124c208430e17508d9a1aae4ee7")
   bower_archive(
+    name = "paper-icon-button",
+    package = "PolymerElements/paper-icon-button",
+    version = "2.1.0",
+    sha1 = "caead6a276877888d128ace809376980c3f3fe42")
+  bower_archive(
     name = "paper-ripple",
     package = "PolymerElements/paper-ripple",
     version = "1.0.10",
diff --git a/lib/js/bower_components.bzl b/lib/js/bower_components.bzl
index fb40855..dc16ccf 100644
--- a/lib/js/bower_components.bzl
+++ b/lib/js/bower_components.bzl
@@ -229,6 +229,16 @@
     seed = True,
   )
   bower_component(
+    name = "paper-icon-button",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-icon",
+      ":paper-behaviors",
+      ":paper-styles",
+      ":polymer",
+    ],
+  )
+  bower_component(
     name = "paper-input",
     license = "//lib:LICENSE-polymer",
     deps = [
@@ -282,6 +292,23 @@
     ],
   )
   bower_component(
+    name = "paper-tabs",
+    license = "//lib:LICENSE-polymer",
+    deps = [
+      ":iron-behaviors",
+      ":iron-flex-layout",
+      ":iron-icon",
+      ":iron-iconset-svg",
+      ":iron-menu-behavior",
+      ":iron-resizable-behavior",
+      ":paper-behaviors",
+      ":paper-icon-button",
+      ":paper-styles",
+      ":polymer",
+    ],
+    seed = True,
+  )
+  bower_component(
     name = "paper-toggle-button",
     license = "//lib:LICENSE-polymer",
     deps = [
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index bac9b33..714fc10 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit bac9b336a326585accfc9a9eda7b5340e62783ac
+Subproject commit 714fc1057174227bd5acf4753039fe582c930e18
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 3f96a82..315a115 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 3f96a82f15d9d592a86a989874b160567cd66f53
+Subproject commit 315a11558913fa8f9c6d3b1723e45583b25afa1c
diff --git a/plugins/replication b/plugins/replication
index 81a34e6..f57f029 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 81a34e608c97e9ac6aa7b2678fca96427e212413
+Subproject commit f57f0297e330660eb699d237cb587d7cc2c0e6b1
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 223730f..4672856 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 223730f10cf16f6a8ed2c0a3867371ca336d9ae7
+Subproject commit 467285664ebf8eb6f1e03ff13ebc706eee6d8662
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 2c42f31..45003c4 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 2c42f316091726ba34a4f3809cb20197d8a46575
+Subproject commit 45003c4e290cd29a8695db438ca30302fa0683c7
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 0c2cd5e..7487ad5 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-tabs",
         "//lib/js:paper-toggle-button",
         "//lib/js:polymer",
         "//lib/js:polymer-resin",
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 991ce2e..593a34b 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -124,6 +124,7 @@
     "change-list",
     "core",
     "diff",
+    "edit",
     "plugins",
     "settings",
     "shared",
@@ -132,7 +133,7 @@
 
 [sh_test(
     name = "template_test_" + directory,
-    size = "large",
+    size = "enormous",
     srcs = ["template_test.sh"],
     args = [directory],
     data = [
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html
index 0148377..d6d5a86 100644
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html
@@ -25,12 +25,23 @@
     /**
      * @template T
      * @param {!Array<T>} array
-     * @param {!Function} fn
+     * @param {!Function} fn An iteratee function to be passed each element of
+     *     the array in order. Must return a promise, and the following
+     *     iteration will not begin until resolution of the promise returned by
+     *     the previous iteration.
+     *
+     *     An optional second argument to fn is a callback that will halt the
+     *     loop if called.
      * @return {!Promise<undefined>}
      */
     asyncForeach(array, fn) {
       if (!array.length) { return Promise.resolve(); }
-      return fn(array[0]).then(() => this.asyncForeach(array.slice(1), fn));
+      let stop = false;
+      const stopCallback = () => { stop = true; };
+      return fn(array[0], stopCallback).then(exit => {
+        if (stop) { return Promise.resolve(); }
+        return this.asyncForeach(array.slice(1), fn);
+      });
     },
   };
 })(window);
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
index ba15ad7..cbc7056 100644
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
@@ -35,5 +35,19 @@
             assert.equal(fn.getCall(2).args[0], 3);
           });
     });
+
+    test('halts on stop condition', () => {
+      const stub = sinon.stub();
+      const fn = (e, stop) => {
+        stub(e);
+        stop();
+        return Promise.resolve();
+      };
+      return Gerrit.AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
+          .then(() => {
+            assert.isTrue(stub.calledOnce);
+            assert.equal(stub.lastCall.args[0], 1);
+          });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index 19eb437..022d994 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
@@ -33,6 +33,7 @@
   /** @polymerBehavior this */
   Gerrit.PatchSetBehavior = {
     EDIT_NAME: 'edit',
+    PARENT_NAME: 'PARENT',
 
     /**
      * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
index f3db039..d3491c7 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
@@ -93,16 +93,20 @@
      * Example
      * // returns 'text.html'
      * util.truncatePath.('text.html');
+     *
+     * @param {string} path
+     * @param {number=} opt_threshold
      * @return {string} Returns the truncated value of a URL.
      */
-    truncatePath(path) {
+    truncatePath(path, opt_threshold) {
+      const threshold = opt_threshold || 1;
       const pathPieces = path.split('/');
 
-      if (pathPieces.length < 2) {
-        return path;
-      }
+      if (pathPieces.length <= threshold) { return path; }
+
+      const index = pathPieces.length - threshold;
       // Character is an ellipsis.
-      return '\u2026/' + pathPieces[pathPieces.length - 1];
+      return `\u2026/${pathPieces.slice(index).join('/')}`;
     },
   };
 })(window);
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
index e0b1b7e..f48fb98 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
@@ -66,6 +66,19 @@
       assert.equal(shortenedPath, expectedPath);
     });
 
+    test('truncatePath with opt_threshold', () => {
+      const truncatePath = Gerrit.PathListBehavior.truncatePath;
+      let path = 'level1/level2/level3/level4/file.js';
+      let shortenedPath = truncatePath(path, 2);
+      // The expected path is truncated with an ellipsis.
+      const expectedPath = '\u2026/level4/file.js';
+      assert.equal(shortenedPath, expectedPath);
+
+      path = 'level2/file.js';
+      shortenedPath = truncatePath(path, 2);
+      assert.equal(shortenedPath, path);
+    });
+
     test('truncatePath with short path should not add ellipsis', () => {
       const truncatePath = Gerrit.PathListBehavior.truncatePath;
       const path = 'file.js';
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 2d84179..be5cd13 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
@@ -68,6 +68,7 @@
           class="confirmDialog"
           disabled="[[!_hasNewGroupName]]"
           confirm-label="Create"
+          confirm-on-enter
           on-confirm="_handleCreateGroup"
           on-cancel="_handleCloseCreate">
         <div class="header" slot="header">
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 da20181..fdfce5a 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
@@ -28,6 +28,7 @@
     </style>
     <gr-confirm-dialog
         confirm-label="Delete [[_computeItemName(itemType)]]"
+        confirm-on-enter
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
       <div class="header" slot="header">[[_computeItemName(itemType)]] Deletion</div>
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 2cfa0b2..8bceff1 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
@@ -35,19 +35,24 @@
       :host {
         display: inline-block;
       }
-      input {
-        width: 25em;
+      input:not([type="checkbox"]),
+      gr-autocomplete,
+      iron-autogrow-textarea {
+        width: 100%;
+      }
+      .value {
+        width: 32em;
+      }
+      section {
+        align-items: center;
+        display: flex;
+      }
+      #description {
+        align-items: initial;
       }
       gr-autocomplete {
-        border: none;
-        float: right;
         --gr-autocomplete: {
-          border: 1px solid #d1d2d3;
-          border-radius: 2px;
-          font-size: var(--font-size-normal);
-          height: 2em;
           padding: 0 .15em;
-          width: 20em;
         }
       }
     </style>
@@ -65,40 +70,50 @@
           </span>
         </section>
         <section>
-          <span class="title">Enter topic for new change (optional)</span>
-          <input
-              is="iron-input"
-              id="tagNameInput"
-              maxlength="1024"
-              bind-value="{{topic}}">
+          <span class="title">Enter topic for new change</span>
+          <span class="value">
+            <input
+                is="iron-input"
+                id="tagNameInput"
+                maxlength="1024"
+                placeholder="(optional)"
+                bind-value="{{topic}}">
+          </span>
         </section>
-        <section>
+        <section id="description">
           <span class="title">Description</span>
-          <iron-autogrow-textarea
-              id="messageInput"
-              class="message"
-              autocomplete="on"
-              rows="4"
-              max-rows="15"
-              bind-value="{{subject}}"
-              placeholder="Insert the description of the change.">
-          </iron-autogrow-textarea>
+          <span class="value">
+            <iron-autogrow-textarea
+                id="messageInput"
+                class="message"
+                autocomplete="on"
+                rows="4"
+                max-rows="15"
+                bind-value="{{subject}}"
+                placeholder="Insert the description of the change.">
+            </iron-autogrow-textarea>
+          </span>
         </section>
         <section>
-          <span class="title">Options</span>
-          <section>
-            <label for="privateChangeCheckBox">Private Change</label>
+          <label
+              class="title"
+              for="privateChangeCheckBox">Private Change</label>
+          <span class="value">
             <input
                 type="checkbox"
                 id="privateChangeCheckBox"
                 checked$="[[_repoConfig.private_by_default.inherited_value]]">
-          </section>
-          <section>
-            <label for="wipChangeCheckBox">WIP Change</label>
+          </span>
+        </section>
+        <section>
+          <label
+              class="title"
+              for="wipChangeCheckBox">WIP Change</label>
+          <span class="value">
             <input
                 type="checkbox"
                 id="wipChangeCheckBox">
-          </section>
+          </span>
         </section>
       </div>
     </div>
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
index b16315e..6c78f7c 100644
--- 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
@@ -52,7 +52,7 @@
     <div class="gr-form-styles">
       <div id="form">
         <section>
-          <span class="title">Repositories name</span>
+          <span class="title">Repository name</span>
           <input is="iron-input"
               id="repoNameInput"
               autocomplete="on"
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 1ce05b1..5257e69 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
@@ -15,6 +15,8 @@
   'use strict';
 
   const SUGGESTIONS_LIMIT = 15;
+  const SAVING_ERROR_TEXT = 'Group may not exist, or you may not have '+
+      'permission to add it';
 
   const URL_REGEX = '^(?:[a-z]+:)?//';
 
@@ -186,7 +188,16 @@
 
     _handleSavingIncludedGroups() {
       return this.$.restAPI.saveIncludedGroup(this._groupName,
-          this._includedGroupSearch)
+          this._includedGroupSearch, err => {
+            if (err.status === 404) {
+              this.dispatchEvent(new CustomEvent('show-alert', {
+                detail: {message: SAVING_ERROR_TEXT},
+                bubbles: true,
+              }));
+              return err;
+            }
+            throw Error(err.statusText);
+          })
           .then(config => {
             if (!config) {
               return;
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 194750e..d670d4d 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
@@ -183,6 +183,24 @@
       });
     });
 
+    test('add included group 404 shows helpful error text', () => {
+      element._groupOwner = true;
+
+      const memberName = 'bad-name';
+      const alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      sandbox.stub(element.$.restAPI, 'saveGroupMembers',
+          () => Promise.reject({status: 404}));
+
+      element.$.groupMemberSearchInput.text = memberName;
+      element.$.groupMemberSearchInput.value = 1234;
+
+      return element._handleSavingIncludedGroups().then(() => {
+        assert.isTrue(alertStub.called);
+      });
+    });
+
     test('_getAccountSuggestions empty', () => {
       return element._getAccountSuggestions('nonexistent').then(accounts => {
         assert.equal(accounts.length, 0);
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
index 77dfad8..e650023 100644
--- 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
@@ -47,9 +47,18 @@
         display: inline-block;
         margin: 1em 0;
       }
+      .weblink {
+        margin-right: .2em;
+      }
+      .weblinks {
+        display: none;
+      }
+      .weblinks.show {
+        display: block;
+      }
     </style>
     <style include="gr-menu-page-styles"></style>
-    <main class$="[[_computeAdminClass(_isAdmin)]]">
+    <main class$="[[_computeAdminClass(_isAdmin, _canUpload)]]">
       <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.
@@ -60,6 +69,14 @@
               [[_inheritsFrom.name]]</a>
         </h3>
       </template>
+      <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
+        History:
+        <template is="dom-repeat" items="[[_weblinks]]" as="link">
+          <a href="[[link.url]]" class="weblink" rel="noopener" target="[[link.target]]">
+            [[link.name]]
+          </a>
+        </template>
+      </div>
       <gr-button id="editBtn"
           class$="[[_computeShowEditClass(_sections)]]"
           on-tap="_handleEdit">[[_editOrCancel(_editing)]]</gr-button>
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
index 65045db..7149292 100644
--- 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
@@ -78,6 +78,11 @@
         type: Boolean,
         value: false,
       },
+      _canUpload: {
+        type: Boolean,
+        value: false,
+      },
+      _ownerOf: Array,
       _capabilities: Object,
       _groups: Object,
       /** @type {?} */
@@ -94,6 +99,7 @@
         value: false,
       },
       _sections: Array,
+      _weblinks: Array,
     },
 
     behaviors: [
@@ -123,6 +129,8 @@
         this._inheritsFrom = res.inherits_from;
         this._local = res.local;
         this._groups = res.groups;
+        this._weblinks = res.config_web_links || [];
+        this._canUpload = res.can_upload;
         return this.toSortedArray(this._local);
       }));
 
@@ -153,6 +161,10 @@
       return editing ? 'Cancel' : 'Edit';
     },
 
+    _computeWebLinkClass(weblinks) {
+      return weblinks.length ? 'show' : '';
+    },
+
     _handleEditingChanged(editing, editingOld) {
       // Ignore when editing gets set initially.
       if (!editingOld || editing) { return; }
@@ -305,8 +317,8 @@
       return 'visible';
     },
 
-    _computeAdminClass(isAdmin) {
-      return isAdmin ? 'admin' : '';
+    _computeAdminClass(isAdmin, canUpload) {
+      return isAdmin || canUpload ? 'admin' : '';
     },
 
     _computeParentHref(repoName) {
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
index dece995..b5efbe3 100644
--- 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
@@ -64,6 +64,12 @@
           name: 'Maintainers',
         },
       },
+      config_web_links: [{
+        name: 'gitiles',
+        target: '_blank',
+        url: 'https://my/site/+log/123/project.config',
+      }],
+      can_upload: true,
     };
     const accessRes2 = {
       local: {
@@ -147,11 +153,15 @@
         assert.deepEqual(element._sections,
             element.toSortedArray(accessRes.local));
         assert.deepEqual(element._labels, repoRes.labels);
+        assert.equal(getComputedStyle(element.$$('.weblinks')).display,
+            'block');
         return element._repoChanged('Another New Repo');
       })
           .then(() => {
             assert.deepEqual(element._sections,
                 element.toSortedArray(accessRes2.local));
+            assert.equal(getComputedStyle(element.$$('.weblinks')).display,
+                'none');
             done();
           });
     });
@@ -202,25 +212,7 @@
     });
 
     suite('with defined sections', () => {
-      setup(() => {
-        // Create deep copies of these objects so the originals are not modified
-        // by any tests.
-        element._local = JSON.parse(JSON.stringify(accessRes.local));
-        element._sections = element.toSortedArray(element._local);
-        element._groups = JSON.parse(JSON.stringify(accessRes.groups));
-        element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
-        element._labels = JSON.parse(JSON.stringify(repoRes.labels));
-        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;
-
+      const testEditSaveCancelBtns = () => {
         // Edit button is visible and Save button is hidden.
         assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
         assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
@@ -237,6 +229,32 @@
         // Save button should be enabled after access is modified
         element.fire('access-modified');
         assert.isFalse(element.$.saveBtn.disabled);
+      };
+
+      setup(() => {
+        // Create deep copies of these objects so the originals are not modified
+        // by any tests.
+        element._local = JSON.parse(JSON.stringify(accessRes.local));
+        element._sections = element.toSortedArray(element._local);
+        element._groups = JSON.parse(JSON.stringify(accessRes.groups));
+        element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
+        element._labels = JSON.parse(JSON.stringify(repoRes.labels));
+        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 non admin with upload privilege', () => {
+        element._canUpload = true;
+        testEditSaveCancelBtns();
+      });
+
+      test('button visibility for admin', () => {
+        element._isAdmin = true;
+        testEditSaveCancelBtns();
       });
 
       test('_handleAccessModified called with event fired', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index 7fb5fe4..0331ff2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -15,6 +15,7 @@
 -->
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
@@ -39,8 +40,7 @@
         background-color: #f5fafd;
       }
       :host([selected]) {
-        border: 1px solid #2a66d9;
-        border-left-width: .5rem;
+        border-left: .5rem solid #2a66d9;
       }
       :host([needs-review]) {
         font-family: var(--font-family-bold);
@@ -100,13 +100,15 @@
       .u-gray-background {
         background-color: #F5F5F5;
       }
+      .placeholder {
+        color: rgba(0, 0, 0, .87);
+      }
       @media only screen and (max-width: 50em) {
         :host {
           display: flex;
         }
         :host([selected]) {
-          border: none;
-          border-top: 1px solid #ddd;
+          border-left: none;
         }
       }
     </style>
@@ -116,7 +118,7 @@
       <gr-change-star change="{{change}}"></gr-change-star>
     </td>
     <td class="cell number" hidden$="[[!showNumber]]" hidden>
-      <a href$="[[changeURL]]"> [[change._number]]</a>
+      <a href$="[[changeURL]]">[[change._number]]</a>
     </td>
     <td class="cell subject"
         hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]">
@@ -134,21 +136,38 @@
     </td>
     <td class="cell status"
         hidden$="[[isColumnHidden('Status', visibleChangeTableColumns)]]">
-      [[changeStatusString(change)]]
+      <template is="dom-if" if="[[status]]">
+        [[status]]
+      </template>
+      <template is="dom-if" if="[[!status]]">
+        <span class="placeholder">--</span>
+      </template>
     </td>
     <td class="cell owner"
         hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]">
-      <gr-account-link account="[[change.owner]]"></gr-account-link>
+      <gr-account-link
+          account="[[change.owner]]"
+          additional-text="[[_computeAccountStatusString(change.owner)]]"></gr-account-link>
     </td>
     <td class="cell assignee"
         hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]">
       <template is="dom-if" if="[[change.assignee]]">
-        <gr-account-link account="[[change.assignee]]"></gr-account-link>
+        <gr-account-link
+            account="[[change.assignee]]"
+            additional-text="[[_computeAccountStatusString(change.owner)]]"></gr-account-link>
+      </template>
+      <template is="dom-if" if="[[!change.assignee]]">
+        <span class="placeholder">--</span>
       </template>
     </td>
     <td class="cell project"
         hidden$="[[isColumnHidden('Project', visibleChangeTableColumns)]]">
-      <a href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
+      <a class="fullProject" href$="[[_computeProjectURL(change.project)]]">
+        [[change.project]]
+      </a>
+      <a class="truncatedProject" href$="[[_computeProjectURL(change.project)]]">
+        [[_computeTruncatedProject(change.project)]]
+      </a>
     </td>
     <td class="cell branch"
         hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]">
@@ -157,7 +176,7 @@
       </a>
       <template is="dom-if" if="[[change.topic]]">
         (<a href$="[[_computeTopicURL(change)]]"><!--
-       --><gr-limited-text limit="30" text="[[change.topic]]">
+       --><gr-limited-text limit="50" text="[[change.topic]]">
           </gr-limited-text><!--
      --></a>)
       </template>
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 aa40a6e..b029d46 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
@@ -29,6 +29,10 @@
         type: String,
         computed: '_computeChangeURL(change)',
       },
+      status: {
+        type: String,
+        computed: 'changeStatusString(change)',
+      },
       showStar: {
         type: Boolean,
         value: false,
@@ -39,6 +43,7 @@
     behaviors: [
       Gerrit.BaseUrlBehavior,
       Gerrit.ChangeTableBehavior,
+      Gerrit.PathListBehavior,
       Gerrit.RESTClientBehavior,
       Gerrit.URLEncodingBehavior,
     ],
@@ -115,5 +120,14 @@
       if (!change.topic) { return ''; }
       return Gerrit.Nav.getUrlForTopic(change.topic);
     },
+
+    _computeTruncatedProject(project) {
+      if (!project) { return ''; }
+      return this.truncatePath(project, 2);
+    },
+
+    _computeAccountStatusString(account) {
+      return account && account.status ? `(${account.status})` : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index 243a8ab..5e44a45 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -188,7 +188,10 @@
     });
 
     test('assignee only displayed if there is one', () => {
+      element.change = {};
+      flushAsynchronousOperations();
       assert.isNotOk(element.$$('.assignee gr-account-link'));
+      assert.equal(element.$$('.assignee').textContent.trim(), '--');
       element.change = {
         assignee: {
           name: 'test',
@@ -197,5 +200,11 @@
       flushAsynchronousOperations();
       assert.isOk(element.$$('.assignee gr-account-link'));
     });
+
+    test('_computeAccountStatusString', () => {
+      assert.equal(element._computeAccountStatusString({}), '');
+      assert.equal(element._computeAccountStatusString({status: 'Working'}),
+          '(Working)');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
index 276febb..f2c7a48 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
@@ -18,6 +18,7 @@
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-change-list/gr-change-list.html">
 <link rel="import" href="../gr-user-header/gr-user-header.html">
@@ -37,15 +38,25 @@
       gr-change-list {
         width: 100%;
       }
+      gr-user-header {
+        border-bottom: 1px solid #ddd;
+      }
       nav {
-        padding: .5em 0;
-        text-align: center;
+        align-items: center;
+        background-color: var(--view-background-color);;
+        display: flex;
+        height: 3rem;
+        justify-content: flex-end;
+        margin-right: 20px;
       }
-      nav a {
-        display: inline-block;
+      nav,
+      iron-icon {
+        color: rgba(0, 0, 0, .87);
       }
-      nav a:first-of-type {
-        margin-right: .5em;
+      iron-icon {
+        height: 1.85rem;
+        margin-left: 16px;
+        width: 1.85rem;
       }
       .hide {
         display: none;
@@ -68,14 +79,18 @@
           changes="{{_changes}}"
           selected-index="{{viewState.selectedChangeIndex}}"
           show-star="[[loggedIn]]"></gr-change-list>
-      <nav>
-        <a id="prevArrow"
-            href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
-            hidden$="[[_hidePrevArrow(_offset)]]" hidden>&larr; Prev</a>
-        <a id="nextArrow"
-            href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
-            hidden$="[[_hideNextArrow(_loading)]]" hidden>
-          Next &rarr;</a>
+      <nav class$="[[_computeNavClass(_loading)]]">
+          Page [[_computePage(_offset, _changesPerPage)]]
+          <a id="prevArrow"
+              href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
+              class$="[[_computePrevArrowClass(_offset)]]">
+            <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+          </a>
+          <a id="nextArrow"
+              href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
+              class$="[[_computeNextArrowClass(_changes)]]">
+            <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+          </a>
       </nav>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index c48b860..890713a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -176,21 +176,20 @@
       offset = +(offset || 0);
       const limit = this._limitFor(query, changesPerPage);
       const newOffset = Math.max(0, offset + (limit * direction));
-      // Double encode URI component.
-      let href = this.getBaseUrl() + '/q/' + this.encodeURL(query, false);
-      if (newOffset > 0) {
-        href += ',' + newOffset;
-      }
-      return href;
+      return Gerrit.Nav.getUrlForSearchQuery(query, newOffset);
     },
 
-    _hidePrevArrow(offset) {
-      return offset === 0;
+    _computePrevArrowClass(offset) {
+      return offset === 0 ? 'hide' : '';
     },
 
-    _hideNextArrow(loading) {
-      return loading || !this._changes || !this._changes.length ||
-          !this._changes[this._changes.length - 1]._more_changes;
+    _computeNextArrowClass(changes) {
+      const more = changes.length && changes[changes.length - 1]._more_changes;
+      return more ? '' : 'hide';
+    },
+
+    _computeNavClass(loading) {
+      return loading || !this._changes || !this._changes.length ? 'hide' : '';
     },
 
     _handleNextPage() {
@@ -217,5 +216,9 @@
     _computeUserHeaderClass(userId) {
       return userId ? '' : 'hide';
     },
+
+    _computePage(offset, changesPerPage) {
+      return offset / changesPerPage + 1;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
index 680f835..c0b1b81 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -46,6 +46,8 @@
         getChanges(num, query) {
           return Promise.resolve([]);
         },
+        getAccountDetails() { return Promise.resolve({}); },
+        getAccountStatus() { return Promise.resolve({}); },
       });
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
@@ -58,15 +60,9 @@
       });
     });
 
-    test('url is properly encoded', () => {
-      assert.equal(element._computeNavLink(
-          'status:open project:platform/frameworks/base', 0, -1, 25),
-          '/q/status:open+project:platform%252Fframeworks%252Fbase'
-      );
-      assert.equal(element._computeNavLink(
-          'status:open project:platform/frameworks/base', 0, 1, 25),
-          '/q/status:open+project:platform%252Fframeworks%252Fbase,25'
-      );
+    test('_computePage', () => {
+      assert.equal(element._computePage(0, 25), 1);
+      assert.equal(element._computePage(50, 25), 3);
     });
 
     test('_limitFor', () => {
@@ -79,74 +75,49 @@
     });
 
     test('_computeNavLink', () => {
+      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForSearchQuery')
+          .returns('');
       const query = 'status:open';
       let offset = 0;
       let direction = 1;
       const changesPerPage = 5;
-      assert.equal(
-          element._computeNavLink(query, offset, direction, changesPerPage),
-          '/q/status:open,5');
+
+      element._computeNavLink(query, offset, direction, changesPerPage);
+      assert.equal(getUrlStub.lastCall.args[1], 5);
+
       direction = -1;
-      assert.equal(
-          element._computeNavLink(query, offset, direction, changesPerPage),
-          '/q/status:open');
+      element._computeNavLink(query, offset, direction, changesPerPage);
+      assert.equal(getUrlStub.lastCall.args[1], 0);
+
       offset = 5;
       direction = 1;
-      assert.equal(
-          element._computeNavLink(query, offset, direction, changesPerPage),
-          '/q/status:open,10');
-      assert.equal(
-          element._computeNavLink(
-              query + ' limit:10', offset, direction, changesPerPage),
-          '/q/status:open+limit:10,15');
+      element._computeNavLink(query, offset, direction, changesPerPage);
+      assert.equal(getUrlStub.lastCall.args[1], 10);
     });
 
-    test('_computeNavLink with path', () => {
-      const oldCanonicalPath = window.CANONICAL_PATH;
-      window.CANONICAL_PATH = '/r';
-      const query = 'status:open';
+    test('_computePrevArrowClass', () => {
       let offset = 0;
-      let direction = 1;
-      const changesPerPage = 5;
-      assert.equal(
-          element._computeNavLink(query, offset, direction, changesPerPage),
-          '/r/q/status:open,5');
-      direction = -1;
-      assert.equal(
-          element._computeNavLink(query, offset, direction, changesPerPage),
-          '/r/q/status:open');
+      assert.equal(element._computePrevArrowClass(offset), 'hide');
       offset = 5;
-      direction = 1;
-      assert.equal(
-          element._computeNavLink(query, offset, direction, changesPerPage),
-          '/r/q/status:open,10');
-      window.CANONICAL_PATH = oldCanonicalPath;
+      assert.equal(element._computePrevArrowClass(offset), '');
     });
 
-    test('_hidePrevArrow', () => {
-      let offset = 0;
-      assert.isTrue(element._hidePrevArrow(offset));
-      offset = 5;
-      assert.isFalse(element._hidePrevArrow(offset));
+    test('_computeNextArrowClass', () => {
+      let changes = _.times(25, _.constant({_more_changes: true}));
+      assert.equal(element._computeNextArrowClass(changes), '');
+      changes = _.times(25, _.constant({}));
+      assert.equal(element._computeNextArrowClass(changes), 'hide');
     });
 
-    test('_hideNextArrow', () => {
+    test('_computeNavClass', () => {
       let loading = true;
-      assert.isTrue(element._hideNextArrow(loading));
+      assert.equal(element._computeNavClass(loading), 'hide');
       loading = false;
-      assert.isTrue(element._hideNextArrow(loading));
+      assert.equal(element._computeNavClass(loading), 'hide');
       element._changes = [];
-      assert.isTrue(element._hideNextArrow(loading));
-      element._changes =
-          Array(...Array(5)).map(Object.prototype.valueOf, {});
-      assert.isTrue(element._hideNextArrow(loading));
-      element._changes =
-          Array(...Array(25)).map(Object.prototype.valueOf,
-          {_more_changes: true});
-      assert.isFalse(element._hideNextArrow(loading));
-      element._changes =
-          Array(...Array(25)).map(Object.prototype.valueOf, {});
-      assert.isTrue(element._hideNextArrow(loading));
+      assert.equal(element._computeNavClass(loading), 'hide');
+      element._changes = _.times(5, _.constant({}));
+      assert.equal(element._computeNavClass(loading), '');
     });
 
     test('_handleNextPage', () => {
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 c8f606b..e129cee 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
@@ -38,6 +38,9 @@
       .hide {
         display: none;
       }
+      gr-user-header {
+        border-bottom: 1px solid #ddd;
+      }
       @media only screen and (max-width: 50em) {
         .loading {
           padding: 0 var(--default-horizontal-margin);
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 6acd9fa..7c04c1f 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
@@ -43,6 +43,12 @@
           '(reviewer:${user} OR assignee:${user})',
     },
     {
+      // Open changes the viewed user is CCed on. Changes ignored by the viewing
+      // user are filtered out.
+      name: 'CCed on',
+      query: 'is:open -is:ignored cc:${user}',
+    },
+    {
       name: 'Recently closed',
       // Closed changes where viewed user is owner, reviewer, or assignee.
       // Changes ignored by the viewing user are filtered out, and so are WIP
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 f7d933a..5621f9c 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
@@ -38,6 +38,11 @@
     let paramsChangedPromise;
 
     setup(() => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+        getAccountDetails() { return Promise.resolve({}); },
+        getAccountStatus() { return Promise.resolve(false); },
+      });
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges',
@@ -234,8 +239,8 @@
     });
 
     test('_computeUserHeaderClass', () => {
-      assert.equal(element._computeUserHeaderClass(undefined), 'hide');
-      assert.equal(element._computeUserHeaderClass(''), 'hide');
+      assert.equal(element._computeUserHeaderClass(undefined), '');
+      assert.equal(element._computeUserHeaderClass(''), '');
       assert.equal(element._computeUserHeaderClass('self'), 'hide');
       assert.equal(element._computeUserHeaderClass('user'), '');
     });
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 5ac5b6d..322de71 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
@@ -68,9 +68,6 @@
           /* px because don't have the same font size */
           margin: 0 0 6px 0;
         }
-        .confirmDialog {
-          width: 90vw;
-        }
         #actionLoadingMessage {
           display: block;
           margin: .5em;
@@ -167,6 +164,7 @@
           id="confirmDeleteDialog"
           class="confirmDialog"
           confirm-label="Delete"
+          confirm-on-enter
           on-cancel="_handleConfirmDialogCancel"
           on-confirm="_handleDeleteConfirm">
         <div class="header" slot="header">
@@ -180,6 +178,7 @@
           id="confirmDeleteEditDialog"
           class="confirmDialog"
           confirm-label="Delete"
+          confirm-on-enter
           on-cancel="_handleConfirmDialogCancel"
           on-confirm="_handleDeleteEditConfirm">
         <div class="header" slot="header">
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 6854f7a..a954559 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
@@ -52,7 +52,6 @@
     ABANDON: 'abandon',
     DELETE: '/',
     DELETE_EDIT: 'deleteEdit',
-    DONE_EDIT: 'doneEdit',
     EDIT: 'edit',
     IGNORE: 'ignore',
     MOVE: 'move',
@@ -63,6 +62,7 @@
     RESTORE: 'restore',
     REVERT: 'revert',
     REVIEWED: 'reviewed',
+    STOP_EDIT: 'stopEdit',
     UNIGNORE: 'unignore',
     UNREVIEWED: 'unreviewed',
     WIP: 'wip',
@@ -159,11 +159,11 @@
     __type: 'change',
   };
 
-  const DONE_EDIT = {
+  const STOP_EDIT = {
     enabled: true,
-    label: 'Done Editing',
+    label: 'Stop editing',
     title: 'Stop editing this change',
-    __key: 'doneEdit',
+    __key: 'stopEdit',
     __primary: false,
     __type: 'change',
   };
@@ -569,14 +569,19 @@
             delete this.actions.edit;
             this.notifyPath('actions.edit');
           }
-          if (!changeActions.doneEdit) {
-            this.set('actions.doneEdit', DONE_EDIT);
-          }
         } else {
           if (!changeActions.edit) { this.set('actions.edit', EDIT); }
-          if (changeActions.doneEdit) {
-            delete this.actions.doneEdit;
-            this.notifyPath('actions.doneEdit');
+        }
+        // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
+        // is loaded.
+        if (editMode && !editPatchsetLoaded) {
+          if (!changeActions.stopEdit) {
+            this.set('actions.stopEdit', STOP_EDIT);
+          }
+        } else {
+          if (changeActions.stopEdit) {
+            delete this.actions.stopEdit;
+            this.notifyPath('actions.stopEdit');
           }
         }
       }
@@ -831,8 +836,8 @@
         case ChangeActions.EDIT:
           this._handleEditTap();
           break;
-        case ChangeActions.DONE_EDIT:
-          this._handleDoneEditTap();
+        case ChangeActions.STOP_EDIT:
+          this._handleStopEditTap();
           break;
         case ChangeActions.DELETE:
           this._handleDeleteTap();
@@ -1316,8 +1321,8 @@
       this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
     },
 
-    _handleDoneEditTap() {
-      this.dispatchEvent(new CustomEvent('done-edit-tap', {bubbles: false}));
+    _handleStopEditTap() {
+      this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
     },
   });
 })();
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 e76e494..8986816 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
@@ -120,7 +120,7 @@
       assert.isFalse(element._shouldHideActions({base: ['test']}, false));
     });
 
-    test('plugin revision actions', () => {
+    test('plugin revision actions', done => {
       sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
           Promise.resolve('the-url'));
       element.revisionActions = {
@@ -131,10 +131,11 @@
         assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
             element.changeNum, element.latestPatchNum, '/plugin~action'));
         assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
+        done();
       });
     });
 
-    test('plugin change actions', () => {
+    test('plugin change actions', done => {
       sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
           Promise.resolve('the-url'));
       element.actions = {
@@ -144,7 +145,8 @@
       flush(() => {
         assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
             element.changeNum, null, '/plugin~action'));
-        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
+        assert.equal(element.actions['plugin~action'].__url, 'the-url');
+        done();
       });
     });
 
@@ -437,7 +439,7 @@
         assert.isOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
         assert.isOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
         assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="doneEdit"]'));
+        assert.isNotOk(element.$$('gr-button[data-action-key="stopEdit"]'));
       });
 
       test('edit patchset is loaded, does not need rebase', () => {
@@ -451,7 +453,7 @@
         assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
         assert.isOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
         assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="doneEdit"]'));
+        assert.isNotOk(element.$$('gr-button[data-action-key="stopEdit"]'));
       });
 
       test('edit mode is loaded, no edit patchset', () => {
@@ -464,7 +466,7 @@
         assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
         assert.isNotOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
         assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="doneEdit"]'));
+        assert.isOk(element.$$('gr-button[data-action-key="stopEdit"]'));
       });
 
       test('normal patch set', () => {
@@ -477,7 +479,7 @@
         assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
         assert.isNotOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
         assert.isOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.$$('gr-button[data-action-key="doneEdit"]'));
+        assert.isNotOk(element.$$('gr-button[data-action-key="stopEdit"]'));
       });
 
       test('edit action', done => {
@@ -487,7 +489,7 @@
         flushAsynchronousOperations();
 
         assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.$$('gr-button[data-action-key="doneEdit"]'));
+        assert.isOk(element.$$('gr-button[data-action-key="stopEdit"]'));
         element.set('editMode', false);
         flushAsynchronousOperations();
 
@@ -629,6 +631,74 @@
       assert.notInclude(element._disabledMenuActions, 'cherrypick');
     });
 
+    suite('abandon change', () => {
+      let alertStub;
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        alertStub = sandbox.stub(window, 'alert');
+        element.actions = {
+          abandon: {
+            method: 'POST',
+            label: 'Abandon',
+            title: 'Abandon the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('abandon change with message', done => {
+        const newAbandonMsg = 'Test Abandon Message';
+        element.$.confirmAbandonDialog.message = newAbandonMsg;
+        flush(() => {
+          const abandonButton =
+              element.$$('gr-button[data-action-key="abandon"]');
+          MockInteractions.tap(abandonButton);
+
+          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+          done();
+        });
+      });
+
+      test('abandon change with no message', done => {
+        flush(() => {
+          const abandonButton =
+              element.$$('gr-button[data-action-key="abandon"]');
+          MockInteractions.tap(abandonButton);
+
+          assert.isUndefined(element.$.confirmAbandonDialog.message);
+          done();
+        });
+      });
+
+      test('works', () => {
+        element.$.confirmAbandonDialog.message = 'original message';
+        const restoreButton =
+            element.$$('gr-button[data-action-key="abandon"]');
+        MockInteractions.tap(restoreButton);
+
+        element.$.confirmAbandonDialog.message = 'foo message';
+        element._handleAbandonDialogConfirm();
+        assert.notOk(alertStub.called);
+
+        const action = {
+          __key: 'abandon',
+          __type: 'change',
+          __primary: false,
+          enabled: true,
+          label: 'Abandon',
+          method: 'POST',
+          title: 'Abandon the change',
+        };
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/abandon', action, false, {
+            message: 'foo message',
+          }]);
+      });
+    });
+
     suite('revert change', () => {
       let alertStub;
       let fireActionStub;
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 58972fd..be1ee6e 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
@@ -63,6 +63,9 @@
         padding: .55em var(--default-horizontal-margin);
         z-index: 99;  /* Less than gr-overlay's backdrop */
       }
+      .header.editMode {
+        background-color: #ebf5fb;
+      }
       .header .download {
         margin-right: 1em;
       }
@@ -81,13 +84,13 @@
       gr-change-status:first-child {
         margin-left: 0;
       }
-      .header-title {
+      .headerTitle {
         align-items: center;
         display: flex;
         flex: 1;
         font-size: 1.2rem;
       }
-      .header-title .headerSubject {
+      .headerTitle .headerSubject {
         font-family: var(--font-family-bold);
       }
       .replyContainer {
@@ -147,7 +150,6 @@
         }
       }
       .changeStatuses,
-      .changeText,
       .commitActions,
       .statusText {
         align-items: center;
@@ -268,7 +270,7 @@
         gr-change-star {
           vertical-align: middle;
         }
-        .header-title {
+        .headerTitle {
           flex-wrap: wrap;
           font-size: 1.1rem;
         }
@@ -321,8 +323,8 @@
         id="mainContent"
         class="container"
         hidden$="{{_loading}}">
-      <div class$="[[_computeHeaderClass(_change)]]">
-        <div class="header-title">
+      <div class$="[[_computeHeaderClass(_editMode)]]">
+        <div class="headerTitle">
           <gr-change-star
               id="changeStar"
               change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
@@ -345,12 +347,11 @@
             </template>
           </div>
           <span class="separator"></span>
-          <div class="changeText">
-            <a aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
-                href$="[[_computeChangeUrl(_change)]]">[[_change._number]]</a>
-            <span class="headerSubject">: [[_change.subject]]</span>
-          </div>
-        </div><!-- end header-title -->
+          <a aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
+              href$="[[_computeChangeUrl(_change)]]">[[_change._number]]</a>
+          <pre>: </pre>
+          <span class="headerSubject">[[_change.subject]]</span>
+        </div><!-- end headerTitle -->
         <div class="commitActions" hidden$="[[!_loggedIn]]">
           <gr-change-actions id="actions"
               change="[[_change]]"
@@ -369,7 +370,7 @@
               edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
               on-reload-change="_handleReloadChange"
               on-edit-tap="_handleEditTap"
-              on-done-edit-tap="_handleDoneEditTap"
+              on-stop-edit-tap="_handleStopEditTap"
               on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
         </div><!-- end commit actions -->
       </div><!-- end header -->
@@ -469,7 +470,7 @@
           </div>
         </div>
       </section>
-      <section class="patchInfo hideOnMobileOverlay">
+      <section class="patchInfo">
         <gr-file-list-header
             id="fileListHeader"
             account="[[_account]]"
@@ -493,7 +494,9 @@
             on-expand-diffs="_expandAllDiffs"
             on-collapse-diffs="_collapseAllDiffs">
         </gr-file-list-header>
-        <gr-file-list id="fileList"
+        <gr-file-list
+            id="fileList"
+            class="hideOnMobileOverlay"
             diff-prefs="{{_diffPrefs}}"
             change="[[_change]]"
             change-num="[[_changeNum]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 5fdef79..028d76a 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
@@ -49,6 +49,11 @@
     NEW_MESSAGE: 'There are new messages on this change',
   };
 
+  const DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
   Polymer({
     is: 'gr-change-view',
 
@@ -251,6 +256,7 @@
       'shift+r': '_handleCapitalRKey',
       'a': '_handleAKey',
       'd': '_handleDKey',
+      'm': '_handleMKey',
       's': '_handleSKey',
       'u': '_handleUKey',
       'x': '_handleXKey',
@@ -311,6 +317,18 @@
       });
     },
 
+    _handleMKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
+        this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+      } else {
+        this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+      }
+    },
+
     _handleEditCommitMessage(e) {
       this._editingCommitMessage = true;
       this.$.commitMessageEditor.focusTextarea();
@@ -1379,8 +1397,10 @@
       this.$.relatedChanges.reload();
     },
 
-    _computeHeaderClass(change) {
-      return change.work_in_progress ? 'header wip' : 'header';
+    _computeHeaderClass(editMode) {
+      const classes = ['header'];
+      if (editMode) { classes.push('editMode'); }
+      return classes.join(' ');
     },
 
     _computeEditMode(patchRangeRecord, paramsRecord) {
@@ -1398,7 +1418,7 @@
         case GrEditConstants.Actions.DELETE.id:
           controls.openDeleteDialog(path);
           break;
-        case GrEditConstants.Actions.EDIT.id:
+        case GrEditConstants.Actions.OPEN.id:
           Gerrit.Nav.navigateToRelativeUrl(
               Gerrit.Nav.getEditUrlForDiff(this._change, path,
                   this._patchRange.patchNum));
@@ -1451,15 +1471,8 @@
       Gerrit.Nav.navigateToChange(this._change, patchNum, null, true);
     },
 
-    /**
-     * Navigate to the latest non-edit patch set.
-     */
-    _handleDoneEditTap() {
-      let patchNum = this._patchRange.patchNum;
-      if (this.patchNumEquals(patchNum, this.EDIT_NAME)) {
-        patchNum = this.computeLatestPatchNum(this._allPatchSets);
-      }
-      Gerrit.Nav.navigateToChange(this._change, patchNum);
+    _handleStopEditTap() {
+      Gerrit.Nav.navigateToChange(this._change, this._patchRange.patchNum);
     },
   });
 })();
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 347100e..2d2bf94 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
@@ -257,6 +257,22 @@
         MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
         assert.isTrue(stub.called);
       });
+
+      test('m should toggle diff mode', () => {
+        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+        const setModeStub = sandbox.stub(element.$.fileListHeader,
+            'setDiffViewMode');
+        const e = {preventDefault: () => {}};
+        flushAsynchronousOperations();
+
+        element.viewState.diffMode = 'SIDE_BY_SIDE';
+        element._handleMKey(e);
+        assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
+
+        element.viewState.diffMode = 'UNIFIED_DIFF';
+        element._handleMKey(e);
+        assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
+      });
     });
 
     suite('reloading drafts', () => {
@@ -1307,12 +1323,11 @@
         assert.isFalse(element._computeCanStartReview(false, change, account1));
         assert.isFalse(element._computeCanStartReview(true, change, account2));
       });
+    });
 
-      test('header class computation', () => {
-        assert.equal(element._computeHeaderClass({}), 'header');
-        assert.equal(element._computeHeaderClass({work_in_progress: true}),
-            'header wip');
-      });
+    test('header class computation', () => {
+      assert.equal(element._computeHeaderClass(), 'header');
+      assert.equal(element._computeHeaderClass(true), 'header editMode');
     });
 
     test('_maybeScrollToMessage', () => {
@@ -1424,9 +1439,9 @@
       assert.isTrue(controls.openRenameDialog.called);
       assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
 
-      // Edit
+      // Open
       fileList.dispatchEvent(new CustomEvent('file-action-tap',
-          {detail: {action: Actions.EDIT.id, path: 'foo'}, bubbles: true}));
+          {detail: {action: Actions.OPEN.id, path: 'foo'}, bubbles: true}));
       flushAsynchronousOperations();
 
       assert.isTrue(Gerrit.Nav.getEditUrlForDiff.called);
@@ -1535,42 +1550,20 @@
       });
     });
 
-    suite('_handleDoneEditTap', () => {
-      let fireDone;
-
-      setup(() => {
-        fireDone = () => {
-          element.$.actions.dispatchEvent(new CustomEvent('done-edit-tap',
-              {bubbles: false}));
-        };
-        sandbox.stub(element.$.metadata, '_computeShowLabelStatus');
-        sandbox.stub(element.$.metadata, '_computeLabelNames');
-        navigateToChangeStub.restore();
+    test('_handleStopEditTap', done => {
+      sandbox.stub(element.$.metadata, '_computeShowLabelStatus');
+      sandbox.stub(element.$.metadata, '_computeLabelNames');
+      navigateToChangeStub.restore();
+      sandbox.stub(element, 'computeLatestPatchNum').returns(1);
+      sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
+        assert.equal(args.length, 2);
+        assert.equal(args[1], 1); // patchNum
+        done();
       });
 
-      test('edit patchset loaded', done => {
-        sandbox.stub(element, 'computeLatestPatchNum').returns(1);
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-          assert.equal(args.length, 2);
-          assert.equal(args[1], 1); // patchNum
-          done();
-        });
-
-        element._patchRange = {patchNum: element.EDIT_NAME};
-        fireDone();
-      });
-
-      test('edit patchset loaded', done => {
-        sandbox.stub(element, 'computeLatestPatchNum').returns(1);
-        sandbox.stub(Gerrit.Nav, 'navigateToChange', (...args) => {
-          assert.equal(args.length, 2);
-          assert.equal(args[1], 1); // patchNum
-          done();
-        });
-
-        element._patchRange = {patchNum: 1};
-        fireDone();
-      });
+      element._patchRange = {patchNum: 1};
+      element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
+            {bubbles: false}));
     });
 
     suite('plugin endpoints', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
new file mode 100644
index 0000000..b757c29
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-abandon-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-confirm-abandon-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-abandon-dialog></gr-confirm-abandon-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-abandon-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_handleConfirmTap', () => {
+      const confirmHandler = sandbox.stub();
+      element.addEventListener('confirm', confirmHandler);
+      sandbox.stub(element, '_confirm');
+      element.$$('gr-confirm-dialog').fire('confirm');
+      assert.isTrue(confirmHandler.called);
+      assert.isTrue(element._confirm.called);
+    });
+
+    test('_handleCancelTap', () => {
+      const cancelHandler = sandbox.stub();
+      element.addEventListener('cancel', cancelHandler);
+      sandbox.stub(element, '_handleCancelTap');
+      element.$$('gr-confirm-dialog').fire('cancel');
+      assert.isTrue(cancelHandler.called);
+      assert.isTrue(element._handleCancelTap.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index 8cd0329..8aa7436 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
@@ -18,6 +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="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../diff/gr-diff-mode-selector/gr-diff-mode-selector.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">
@@ -38,9 +39,6 @@
       .collapseToggleButton {
         text-decoration: none;
       }
-      .patchInfoEdit.patchInfo-header {
-        background-color: #fcfad6;
-      }
       .patchInfoOldPatchSet.patchInfo-header {
         background-color: #fff9c4;
       }
@@ -99,9 +97,6 @@
       .expanded #expandBtn {
         display: none;
       }
-      gr-button.selected iron-icon {
-        color: var(--color-link);
-      }
       gr-linked-chip {
         --linked-chip-text-color: black;
       }
@@ -140,9 +135,13 @@
         font-family: var(--font-family-bold);
         margin-right: 24px;
       }
-      gr-commit-info {
+      gr-commit-info,
+      gr-edit-controls {
         margin-right: -5px;
       }
+      .fileViewActionsLabel {
+        margin-right: .2rem;
+      }
       @media screen and (max-width: 50em) {
         .patchInfo-header .desktop {
           display: none;
@@ -232,21 +231,11 @@
         </template>
         <div class="fileViewActions">
           <span class="separator"></span>
-          <span>Diff Views:</span>
-          <gr-button
-              id="sideBySideBtn"
-              link
-              has-tooltip
-              title="Side-by-side diff"
-              class$="[[_computeSelectedClass(diffViewMode, _VIEW_MODES.SIDE_BY_SIDE)]]"
-              on-tap="_handleSideBySideTap"><iron-icon icon="gr-icons:side-by-side"></iron-icon></gr-button>
-          <gr-button
-              id="unifiedBtn"
-              link
-              has-tooltip
-              title="Unified dff"
-              class$="[[_computeSelectedClass(diffViewMode, _VIEW_MODES.UNIFIED)]]"
-              on-tap="_handleUnifiedTap"><iron-icon icon="gr-icons:unified"></iron-icon></gr-button>
+          <span class="fileViewActionsLabel">Diff view:</span>
+          <gr-diff-mode-selector
+              id="modeSelect"
+              mode="{{diffViewMode}}"
+              save-on-change="[[loggedIn]]"></gr-diff-mode-selector>
           <span id="diffPrefsContainer"
               class="hideOnEdit"
               hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
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 9d32e75..84add8a 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
@@ -56,15 +56,6 @@
         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)',
@@ -79,6 +70,10 @@
       '_computePatchSetDescription(change, patchNum)',
     ],
 
+    setDiffViewMode(mode) {
+      this.$.modeSelect.setMode(mode);
+    },
+
     _expandAllDiffs() {
       this._expanded = true;
       this.fire('expand-diffs');
@@ -89,10 +84,6 @@
       this.fire('collapse-diffs');
     },
 
-    _computeSelectedClass(diffViewMode, buttonViewMode) {
-      return buttonViewMode === diffViewMode ? 'selected' : '';
-    },
-
     _computeExpandedClass(filesExpanded) {
       const classes = [];
       if (filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
@@ -105,14 +96,6 @@
       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';
     },
@@ -206,10 +189,6 @@
     },
 
     _computePatchInfoClass(patchNum, allPatchSets) {
-      if (this.patchNumEquals(patchNum, this.EDIT_NAME)) {
-        return 'patchInfoEdit';
-      }
-
       const latestNum = this.computeLatestPatchNum(allPatchSets);
       if (this.patchNumEquals(patchNum, latestNum)) {
         return '';
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 16cbfcb..a92d783 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
@@ -194,19 +194,6 @@
       });
     });
 
-    test('diff mode selector is set correctly', () => {
-      const sideBySideBtn = element.$.sideBySideBtn;
-      const unifiedBtn = element.$.unifiedBtn;
-      element.diffViewMode = 'SIDE_BY_SIDE';
-      flushAsynchronousOperations();
-      assert.isTrue(sideBySideBtn.classList.contains('selected'));
-      assert.isFalse(unifiedBtn.classList.contains('selected'));
-      element.diffViewMode = 'UNIFIED_DIFF';
-      flushAsynchronousOperations();
-      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');
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 bbfbe7f..68d7442 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
@@ -209,7 +209,8 @@
         opacity: 100;
       }
       .row:hover .reviewed label,
-      .row:focus .reviewed label {
+      .row:focus .reviewed label,
+      .row.expanded .reviewed label {
         opacity: 100;
       }
       .reviewed input {
@@ -225,8 +226,13 @@
         opacity: 100;
       }
       .editFileControls {
-        margin-left: 1em;
-        width: 10em;
+        width: 7em;
+      }
+      .markReviewed,
+      .pathLink {
+        display: inline-block;
+        margin: -.2em 0;
+        padding: .4em 0;
       }
       @media screen and (max-width: 50em) {
         .desktop {
@@ -279,9 +285,9 @@
               [[_computeFileStatus(file.status)]]
             </div>
             <span
-                data-url="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path)]]"
+                data-url="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path, editMode)]]"
                 class="path">
-              <a class="pathLink" href$="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path)]]">
+              <a class="pathLink" href$="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path, editMode)]]">
                 <span title$="[[computeDisplayPath(file.__path)]]"
                     class="fullFileName">
                   [[computeDisplayPath(file.__path)]]
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 b42824c..a326f54 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
@@ -137,6 +137,9 @@
         value: true,
         computed: '_computeShowSizeBars(_userPrefs)',
       },
+
+      /** @type {Function} */
+      _cancelForEachDiff: Function,
     },
 
     behaviors: [
@@ -174,6 +177,10 @@
       keydown: '_scopedKeydownHandler',
     },
 
+    detached() {
+      this._cancelDiffs();
+    },
+
     /**
      * 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
@@ -435,6 +442,7 @@
       while (!row.classList.contains('row') && row.parentElement) {
         row = row.parentElement;
       }
+
       const path = row.dataset.path;
       // Handle checkbox mark as reviewed.
       if (e.target.classList.contains('markReviewed')) {
@@ -526,7 +534,7 @@
       const isRangeSelected = this.diffs.some(diff => {
         return diff.isRangeSelected();
       }, this);
-      if (this._showInlineDiffs && !isRangeSelected) {
+      if (!isRangeSelected) {
         e.preventDefault();
         this._addDraftAtTarget();
       }
@@ -660,7 +668,13 @@
       return status || 'M';
     },
 
-    _computeDiffURL(change, patchNum, basePatchNum, path) {
+    _computeDiffURL(change, patchNum, basePatchNum, path, editMode) {
+      // TODO(kaspern): Fix editing for commit messages and merge lists.
+      if (editMode && path !== this.COMMIT_MESSAGE_PATH &&
+          path !== this.MERGE_LIST_PATH) {
+        return Gerrit.Nav.getEditUrlForDiff(change, path, patchNum,
+            basePatchNum);
+      }
       return Gerrit.Nav.getUrlForDiff(change, path, patchNum, basePatchNum);
     },
 
@@ -849,6 +863,7 @@
 
     _clearCollapsedDiffs(collapsedDiffs) {
       for (const diff of collapsedDiffs) {
+        diff.cancel();
         diff.clearDiffContent();
       }
     },
@@ -871,7 +886,9 @@
       return (new Promise(resolve => {
         this.fire('reload-drafts', {resolve});
       })).then(() => {
-        return this.asyncForeach(paths, path => {
+        return this.asyncForeach(paths, (path, cancel) => {
+          this._cancelForEachDiff = cancel;
+
           iter++;
           console.log('Expanding diff', iter, 'of', initialCount, ':',
               path);
@@ -884,6 +901,7 @@
           }
           return Promise.all(promises);
         }).then(() => {
+          this._cancelForEachDiff = null;
           this._nextRenderParams = null;
           console.log('Finished expanding', initialCount, 'diff(s)');
           this.$.reporting.timeEnd(timerName);
@@ -892,6 +910,12 @@
       });
     },
 
+    /** Cancel the rendering work of every diff in the list */
+    _cancelDiffs() {
+      if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
+      this._forEachDiff(d => d.cancel());
+    },
+
     /**
      * In the given NodeList of diff elements, find the diff for the given path.
      * @param  {string} path
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 ffd91a4..38ab31b 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
@@ -494,6 +494,10 @@
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.$.fileCursor.index, 0);
         assert.equal(element.selectedIndex, 0);
+
+        sandbox.stub(element, '_addDraftAtTarget');
+        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
+        assert.isTrue(element._addDraftAtTarget.called);
       });
 
       test('i key shows/hides selected inline diff', () => {
@@ -851,6 +855,7 @@
         reload() {
           done();
         },
+        cancel() {},
       }];
       sinon.stub(element, 'diffs', {
         get() { return diffs; },
@@ -858,6 +863,16 @@
       element.push('_expandedFilePaths', path);
     });
 
+    test('_clearCollapsedDiffs', () => {
+      const diff = {
+        cancel: sinon.stub(),
+        clearDiffContent: sinon.stub(),
+      };
+      element._clearCollapsedDiffs([diff]);
+      assert.isTrue(diff.cancel.calledOnce);
+      assert.isTrue(diff.clearDiffContent.calledOnce);
+    });
+
     test('filesExpanded value updates to correct enum', () => {
       element._files = [{__path: 'foo.bar'}, {__path: 'baz.bar'}];
       flushAsynchronousOperations();
@@ -975,6 +990,7 @@
       element.change = {_number: 123};
       element.patchRange = {patchNum: undefined, basePatchNum: 'PARENT'};
       element._files = [{__path: 'foo/bar.cpp'}];
+      element.editMode = false;
       flush(() => {
         assert.isFalse(urlStub.called);
         element.set('patchRange.patchNum', 4);
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 6b4499f..0e73c6e 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -16,7 +16,7 @@
 
   const CI_LABELS = ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'];
   const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+: /;
-  const LABEL_TITLE_SCORE_PATTERN = /([A-Za-z0-9-]+)([+-]\d+)/;
+  const LABEL_TITLE_SCORE_PATTERN = /^([A-Za-z0-9-]+)([+-]\d+)$/;
 
   Polymer({
     is: 'gr-message',
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index aa18c4e..658dcad 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -180,5 +180,14 @@
       };
       assert.isOk(Polymer.dom(element.root).querySelector('.positiveVote'));
     });
+
+    test('false negative vote', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
+      };
+      assert.isNotOk(Polymer.dom(element.root).querySelector('.negativeVote'));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index cd737c4..ea98fb8 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
@@ -126,17 +126,17 @@
       </section>
       <section hidden$="[[!_submittedTogether.length]]" hidden>
         <h4>Submitted together</h4>
-        <template is="dom-repeat" items="[[_submittedTogether]]" as="change">
-          <div>
-            <a href$="[[_computeChangeURL(change._number, change.project)]]"
-                class$="[[_computeLinkClass(change)]]"
-                title$="[[change.project]]: [[change.branch]]: [[change.subject]]">
-              [[change.project]]: [[change.branch]]: [[change.subject]]
+        <template is="dom-repeat" items="[[_submittedTogether]]" as="related">
+          <div class$="[[_computeChangeContainerClass(change, related)]]">
+            <a href$="[[_computeChangeURL(related._number, related.project)]]"
+                class$="[[_computeLinkClass(related)]]"
+                title$="[[related.project]]: [[related.branch]]: [[related.subject]]">
+              [[related.project]]: [[related.branch]]: [[related.subject]]
             </a>
             <span
                 tabindex="-1"
                 title="Submittable"
-                class$="submittableCheck [[_computeLinkClass(change)]]">โœ“</span>
+                class$="submittableCheck [[_computeLinkClass(related)]]">โœ“</span>
           </div>
         </template>
       </section>
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 46b803f..6deb9a0 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
@@ -171,8 +171,7 @@
         </template>
         <gr-overlay
             id="reviewerConfirmationOverlay"
-            on-iron-overlay-canceled="_cancelPendingReviewer"
-            with-backdrop>
+            on-iron-overlay-canceled="_cancelPendingReviewer">
           <div class="reviewerConfirmation">
             Group
             <span class="groupName">
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 b1e6a5a..348eabb 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
@@ -130,13 +130,13 @@
     },
 
     _computeReviewerTooltip(reviewer, change) {
-      if (!change || !change.permitted_labels) return '';
+      if (!change || !change.labels) { return ''; }
       const maxScores = [];
       const maxPermitted = this._getMaxPermittedScores(change);
-      for (const label of Object.keys(change.permitted_labels)) {
+      for (const label of Object.keys(change.labels)) {
         const maxScore =
               this._getReviewerPermittedScore(reviewer, change, label);
-        if (isNaN(maxScore) || maxScore < 0) continue;
+        if (isNaN(maxScore) || maxScore < 0) { continue; }
         if (maxScore > 0 && maxScore === maxPermitted[label]) {
           maxScores.push(`${label}: +${maxScore}`);
         } else {
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 985e4bb..24fc4d1 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
@@ -307,7 +307,6 @@
         },
         permitted_labels: {
           Foo: ['-1', ' 0', '+1', '+2'],
-          Bar: ['-1', ' 0', '+1', '+2'],
           FooBar: ['-1', ' 0'],
         },
       };
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
new file mode 100644
index 0000000..2f1da97
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.html
@@ -0,0 +1,57 @@
+<!--
+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="../../../bower_components/paper-toggle-button/paper-toggle-button.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../diff/gr-diff-comment-thread/gr-diff-comment-thread.html">
+
+
+<dom-module id="gr-thread-list">
+  <template>
+    <style include="shared-styles">
+      .draftsOnly gr-diff-comment-thread,
+      .unresolvedOnly gr-diff-comment-thread {
+        display: none
+      }
+      .draftsOnly:not(.unresolvedOnly) gr-diff-comment-thread[has-draft],
+      .unresolvedOnly:not(.draftsOnly) gr-diff-comment-thread[unresolved],
+      .draftsOnly.unresolvedOnly gr-diff-comment-thread[has-draft][unresolved] {
+        display: block
+      }
+    </style>
+    <div id="threads">
+      <div>
+        <paper-toggle-button
+            id="unresolvedToggle"
+            on-change="_toggleUnresolved"></paper-toggle-button>Only Unresolved threads
+        <paper-toggle-button
+            id="draftToggle"
+            on-change="_toggleDrafts"></paper-toggle-button>Only threads with drafts
+      </div>
+      <template is="dom-repeat" items="[[threads]]" as="thread">
+          <a href$="[[_getDiffUrlForComment(change, thread.path, thread.patchNum, thread.line)]]">[[thread.path]]</a>
+          <gr-diff-comment-thread
+              comments="[[thread.comments]]"
+              comment-side="[[thread.commentSide]]"
+              patch-num="[[thread.patchNum]]"
+              path="[[thread.path]]"></gr-diff-comment-thread>
+      </template>
+    </div>
+  </template>
+  <script src="gr-thread-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
new file mode 100644
index 0000000..1e4a69e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -0,0 +1,38 @@
+// 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-thread-list',
+
+    properties: {
+      change: Object,
+      threads: Array,
+      changeNum: String,
+    },
+
+    _toggleUnresolved() {
+      this.$.threads.classList.toggle('unresolvedOnly');
+    },
+
+    _toggleDrafts() {
+      this.$.threads.classList.toggle('draftsOnly');
+    },
+
+    _getDiffUrlForComment(change, path, patchNum, lineNum) {
+      return Gerrit.Nav.getUrlForDiff(change, path, patchNum, null, lineNum);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
new file mode 100644
index 0000000..0fef3d6d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
@@ -0,0 +1,180 @@
+<!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-thread-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-thread-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-thread-list></gr-thread-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-thread-list tests', () => {
+    let element;
+    let sandbox;
+    let threadsElements;
+    const computeVisibleNumber = threads => {
+      let count = 0;
+      for (const thread of threads) {
+        if (getComputedStyle(thread).display !== 'none') {
+          count++;
+        }
+      }
+      return count;
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.threads = [
+        {
+          comments: [
+            {
+              __path: '/COMMIT_MSG',
+              author: {
+                _account_id: 1000000,
+                name: 'user',
+                username: 'user',
+              },
+              patch_set: 4,
+              id: 'ecf0b9fa_fe1a5f62',
+              line: 5,
+              updated: '2018-02-08 18:49:18.000000000',
+              message: 'test',
+              unresolved: true,
+            },
+            {
+              id: '503008e2_0ab203ee',
+              path: '/COMMIT_MSG',
+              line: 5,
+              in_reply_to: 'ecf0b9fa_fe1a5f62',
+              updated: '2018-02-13 22:48:48.018000000',
+              message: 'draft',
+              unresolved: true,
+              __draft: true,
+              __draftID: '0.m683trwff68',
+              __editing: false,
+              patch_set: '2',
+            },
+          ],
+          patchNum: 4,
+          path: '/COMMIT_MSG',
+          line: 5,
+          rootId: 'ecf0b9fa_fe1a5f62',
+          start_datetime: '2018-02-08 18:49:18.000000000',
+        },
+        {
+          comments: [
+            {
+              __path: 'test.txt',
+              author: {
+                _account_id: 1000000,
+                name: 'user',
+                username: 'user',
+              },
+              patch_set: 3,
+              id: '09a9fb0a_1484e6cf',
+              side: 'PARENT',
+              updated: '2018-02-13 22:47:19.000000000',
+              message: 'Some comment on another patchset.',
+              unresolved: false,
+            },
+          ],
+          patchNum: 3,
+          path: 'test.txt',
+          rootId: '09a9fb0a_1484e6cf',
+          start_datetime: '2018-02-13 22:47:19.000000000',
+          commentSide: 'PARENT',
+        },
+        {
+          comments: [
+            {
+              __path: '/COMMIT_MSG',
+              author: {
+                _account_id: 1000000,
+                name: 'user',
+                username: 'user',
+              },
+              patch_set: 2,
+              id: '8caddf38_44770ec1',
+              line: 4,
+              updated: '2018-02-13 22:48:40.000000000',
+              message: 'Another unresolved comment',
+              unresolved: true,
+            },
+          ],
+          patchNum: 2,
+          path: '/COMMIT_MSG',
+          line: 4,
+          rootId: '8caddf38_44770ec1',
+          start_datetime: '2018-02-13 22:48:40.000000000',
+        },
+      ];
+      flushAsynchronousOperations();
+      threadsElements = Polymer.dom(element.root)
+          .querySelectorAll('gr-diff-comment-thread');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('there are three threads by default', () => {
+      assert.equal(computeVisibleNumber(threadsElements), 3);
+    });
+
+    test('toggle unresolved only shows unressolved comments', () => {
+      MockInteractions.tap(element.$.unresolvedToggle);
+      flushAsynchronousOperations();
+      assert.equal(computeVisibleNumber(threadsElements), 2);
+    });
+
+    test('toggle drafts only shows threads with draft comments', () => {
+      MockInteractions.tap(element.$.draftToggle);
+      flushAsynchronousOperations();
+      assert.equal(computeVisibleNumber(threadsElements), 1);
+    });
+
+    test('toggle drafts and unresolved only shows threads with drafts and ' +
+        'unresolved', () => {
+      MockInteractions.tap(element.$.draftToggle);
+      MockInteractions.tap(element.$.unresolvedToggle);
+      flushAsynchronousOperations();
+      assert.equal(computeVisibleNumber(threadsElements), 1);
+    });
+
+    test('_getDiffUrlForComment', () => {
+      sandbox.stub(Gerrit.Nav, 'getUrlForDiff');
+      const change = {_number: 123, project: 'demo-project'};
+      const path = '/path';
+      const patchNum = 2;
+      const lineNum = 5;
+      element._getDiffUrlForComment(change, path, patchNum, lineNum);
+      assert.isTrue(Gerrit.Nav.getUrlForDiff.lastCall.calledWithExactly(
+          change, path, patchNum, null, lineNum));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index c30f726..0d8bbbb 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
@@ -379,6 +379,12 @@
           </tr>
           <tr>
             <td>
+              <span class="key">m</span>
+            </td>
+            <td>Toggle unified/side-by-side diff</td>
+          </tr>
+          <tr>
+            <td>
               <span class="key">c</span>
             </td>
             <td>Draft new comment</td>
@@ -453,6 +459,12 @@
           </tr>
           <tr>
             <td>
+              <span class="key">m</span>
+            </td>
+            <td>Toggle unified/side-by-side diff</td>
+          </tr>
+          <tr>
+            <td>
               <span class="key">c</span>
             </td>
             <td>Draft new comment</td>
@@ -486,11 +498,9 @@
             <td><span class="key">,</span></td>
             <td>Show diff preferences</td>
           </tr>
-         <tr>
-            <td>
-              <span class="key">v</span>
-            </td>
-            <td>Toggle Unified/Side-by-side diff</td>
+          <tr>
+            <td><span class="key">r</span></td>
+            <td>Mark/unmark file as reviewed</td>
           </tr>
         </tbody>
       </table>
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 fcb4ecc..4647e52 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -45,6 +45,7 @@
     //    - `statuses`, optional, Array<String>: the list of change statuses to
     //        search for. If more than one is provided, the search will OR them
     //        together.
+    //    - `offset`, optional, Number: the offset for the query.
     //
     //  - Gerrit.Nav.View.DIFF:
     //    - `changeNum`, required, String: the numeric ID of the change.
@@ -163,10 +164,11 @@
         return this._generateUrl(params);
       },
 
-      getUrlForSearchQuery(query) {
+      getUrlForSearchQuery(query, opt_offset) {
         return this._getUrlFor({
           view: Gerrit.Nav.View.SEARCH,
           query,
+          offset: opt_offset,
         });
       },
 
@@ -291,11 +293,12 @@
        * @param {number=} opt_patchNum
        * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
        *     used for none.
+       * @param {number|string=} opt_lineNum
        * @return {string}
        */
-      getUrlForDiff(change, path, opt_patchNum, opt_basePatchNum) {
+      getUrlForDiff(change, path, opt_patchNum, opt_basePatchNum, opt_lineNum) {
         return this.getUrlForDiffById(change._number, change.project, path,
-            opt_patchNum, opt_basePatchNum);
+            opt_patchNum, opt_basePatchNum, opt_lineNum);
       },
 
       /**
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 0d38ce0..322d406 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -334,8 +334,13 @@
      * @return {string}
      */
     _generateSearchUrl(params) {
+      let offsetExpr = '';
+      if (params.offset && params.offset > 0) {
+        offsetExpr = ',' + params.offset;
+      }
+
       if (params.query) {
-        return '/q/' + this.encodeURL(params.query, true);
+        return '/q/' + this.encodeURL(params.query, true) + offsetExpr;
       }
 
       const operators = [];
@@ -367,7 +372,8 @@
               ')');
         }
       }
-      return '/q/' + operators.join('+');
+
+      return '/q/' + operators.join('+') + offsetExpr;
     },
 
     /**
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 f0ec58d..d0beb91 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
@@ -230,10 +230,19 @@
             '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
             'topic:"g%2525h"+status:op%2525en');
 
+        params.offset = 100;
+        assert.equal(element._generateUrl(params),
+            '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+            'topic:"g%2525h"+status:op%2525en,100');
+        delete params.offset;
+
         // The presence of the query param overrides other params.
         params.query = 'foo$bar';
         assert.equal(element._generateUrl(params), '/q/foo%2524bar');
 
+        params.offset = 100;
+        assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
+
         params = {
           view: Gerrit.Nav.View.SEARCH,
           statuses: ['a', 'b', 'c'],
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 9f7c5c0..522ee52 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
@@ -349,20 +349,33 @@
     return result;
   };
 
+  /**
+   * @param {number} changeNum
+   * @param {number|string} patchNum
+   * @param {string} path
+   * @param {boolean} isOnParent
+   * @param {string} commentSide
+   * @return {!Object}
+   */
   GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum,
-      patchNum, path, isOnParent, range) {
+      patchNum, path, isOnParent, commentSide) {
     const threadGroupEl =
         document.createElement('gr-diff-comment-thread-group');
     threadGroupEl.changeNum = changeNum;
+    threadGroupEl.commentSide = commentSide;
     threadGroupEl.patchForNewThreads = patchNum;
     threadGroupEl.path = path;
     threadGroupEl.isOnParent = isOnParent;
     threadGroupEl.projectName = this._projectName;
-    threadGroupEl.range = range;
     threadGroupEl.parentIndex = this._parentIndex;
     return threadGroupEl;
   };
 
+  /**
+   * @param {number} line
+   * @param {string=} opt_side
+   * @return {!Object}
+   */
   GrDiffBuilder.prototype._commentThreadGroupForLine = function(
       line, opt_side) {
     const comments =
@@ -385,7 +398,7 @@
     }
     const threadGroupEl = this.createCommentThreadGroup(
         this._comments.meta.changeNum, patchNum, this._comments.meta.path,
-        isOnParent);
+        isOnParent, opt_side);
     threadGroupEl.comments = comments;
     if (opt_side) {
       threadGroupEl.setAttribute('data-side', opt_side);
@@ -411,9 +424,11 @@
     } else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
       td.classList.add('contextLineNum');
       td.setAttribute('data-value', '@@');
+      td.textContent = '@@';
     } else if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
       td.classList.add('lineNum');
       td.setAttribute('data-value', number);
+      td.textContent = number === 'FILE' ? 'File' : number;
     }
     return td;
   };
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 bf94bb6..407cf93 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
@@ -32,14 +32,15 @@
     <template is="dom-repeat" items="[[_threads]]" as="thread">
       <gr-diff-comment-thread
           comments="[[thread.comments]]"
-          comment-side="[[thread.commentSide]]"
+          comment-side="[[commentSide]]"
           is-on-parent="[[isOnParent]]"
           parent-index="[[parentIndex]]"
           change-num="[[changeNum]]"
-          location-range="[[thread.locationRange]]"
           patch-num="[[thread.patchNum]]"
+          root-id="[[thread.rootId]]"
           path="[[path]]"
-          project-name="[[projectName]]"></gr-diff-comment-thread>
+          project-name="[[projectName]]"
+          range="[[thread.range]]"></gr-diff-comment-thread>
     </template>
   </template>
   <script src="gr-diff-comment-thread-group.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
index 6978d05..1654235 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
@@ -19,6 +19,7 @@
 
     properties: {
       changeNum: String,
+      commentSide: String,
       comments: {
         type: Array,
         value() { return []; },
@@ -44,36 +45,66 @@
       '_commentsChanged(comments.*)',
     ],
 
-    addNewThread(locationRange) {
+    /**
+     * Adds a new thread. Range is optional because a comment can be
+     * added to a line without a range selected.
+     *
+     * @param {!Object} opt_range
+     */
+    addNewThread(opt_range) {
       this.push('_threads', {
         comments: [],
-        locationRange,
         patchNum: this.patchForNewThreads,
+        range: opt_range,
       });
     },
 
-    removeThread(locationRange) {
+    removeThread(rootId) {
       for (let i = 0; i < this._threads.length; i++) {
-        if (this._threads[i].locationRange === locationRange) {
+        if (this._threads[i].rootId === rootId) {
           this.splice('_threads', i, 1);
           return;
         }
       }
     },
 
-    getThreadForRange(rangeToCheck) {
-      const threads = [].filter.call(
-          Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread'),
-          thread => {
-            return thread.locationRange === rangeToCheck;
-          });
+    /**
+     * Fetch the thread group at the given range, or the range-less thread
+     * on the line if no range is provided.
+     *
+     * @param {!Object=} opt_range
+     * @return {!Object|undefined}
+     */
+    getThread(opt_range) {
+      const threadEls =
+          Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
+      const threads = [].filter.call(threadEls,
+          thread => this._rangesEqual(thread.range, opt_range));
       if (threads.length === 1) {
         return threads[0];
       }
     },
 
+    /**
+     * Compare two ranges. Either argument may be falsy, but will only return
+     * true if both are falsy or if neither are falsy and have the same position
+     * values.
+     *
+     * @param {Object=} a range 1
+     * @param {Object=} b range 2
+     * @return {boolean}
+     */
+    _rangesEqual(a, b) {
+      if (!a && !b) { return true; }
+      if (!a || !b) { return false; }
+      return a.startLine === b.startLine &&
+          a.startChar === b.startChar &&
+          a.endLine === b.endLine &&
+          a.endChar === b.endChar;
+    },
+
     _commentsChanged() {
-      this._threads = this._getThreadGroups(this.comments);
+      this._threads = this._getThreads(this.comments);
     },
 
     _sortByDate(threadGroups) {
@@ -110,19 +141,12 @@
       return comment.patchNum || this.patchForNewThreads;
     },
 
-    _getThreadGroups(comments) {
+    _getThreads(comments) {
       const sortedComments = comments.slice(0).sort((a, b) =>
           util.parseDate(a.updated) - util.parseDate(b.updated));
 
       const threads = [];
       for (const comment of sortedComments) {
-        let locationRange;
-        if (!comment.range) {
-          locationRange = 'line-' + comment.__commentSide;
-        } else {
-          locationRange = this._calculateLocationRange(comment.range, comment);
-        }
-
         // If the comment is in reply to another comment, find that comment's
         // thread and append to it.
         if (comment.in_reply_to) {
@@ -135,13 +159,17 @@
         }
 
         // Otherwise, this comment starts its own thread.
-        threads.push({
+        const newThread = {
           start_datetime: comment.updated,
           comments: [comment],
-          locationRange,
           commentSide: comment.__commentSide,
           patchNum: this._getPatchNum(comment),
-        });
+          rootId: comment.id,
+        };
+        if (comment.range) {
+          newThread.range = Object.assign({}, comment.range);
+        }
+        threads.push(newThread);
       }
       return threads;
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
index 713a162..0abd0b8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
@@ -50,7 +50,7 @@
       sandbox.restore();
     });
 
-    test('_getThreadGroups', () => {
+    test('_getThreads', () => {
       element.patchForNewThreads = 3;
       const comments = [
         {
@@ -83,13 +83,12 @@
             __commentSide: 'left',
             in_reply_to: 'sallys_confession',
           }],
-          locationRange: 'line-left',
+          rootId: 'sallys_confession',
           patchNum: 3,
         },
       ];
 
-      assert.deepEqual(element._getThreadGroups(comments),
-          expectedThreadGroups);
+      assert.deepEqual(element._getThreads(comments), expectedThreadGroups);
 
       // Patch num should get inherited from comment rather
       comments.push({
@@ -122,7 +121,7 @@
             __commentSide: 'left',
           }],
           patchNum: 3,
-          locationRange: 'line-left',
+          rootId: 'sallys_confession',
         },
         {
           start_datetime: '2015-12-24 15:00:10.396000000',
@@ -140,12 +139,17 @@
             __commentSide: 'left',
           }],
           patchNum: 3,
-          locationRange: 'range-1-1-1-2-left',
+          rootId: 'betsys_confession',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
         },
       ];
 
-      assert.deepEqual(element._getThreadGroups(comments),
-          expectedThreadGroups);
+      assert.deepEqual(element._getThreads(comments), expectedThreadGroups);
     });
 
     test('multiple comments at same location but not threaded', () => {
@@ -163,7 +167,7 @@
           __commentSide: 'left',
         },
       ];
-      assert.equal(element._getThreadGroups(comments).length, 2);
+      assert.equal(element._getThreads(comments).length, 2);
     });
 
     test('_sortByDate', () => {
@@ -277,5 +281,23 @@
       flushAsynchronousOperations();
       assert(element._threads.length, 1);
     });
+
+    test('_rangesEqual', () => {
+      const range1 =
+          {startLine: 123, startChar: 345, endLine: 234, endChar: 456};
+      const range2 =
+          {startLine: 1, startChar: 2, endLine: 3, endChar: 4};
+
+      assert.isTrue(element._rangesEqual(null, null));
+      assert.isTrue(element._rangesEqual(null, undefined));
+      assert.isTrue(element._rangesEqual(undefined, null));
+      assert.isTrue(element._rangesEqual(undefined, undefined));
+
+      assert.isFalse(element._rangesEqual(range1, null));
+      assert.isFalse(element._rangesEqual(null, range1));
+      assert.isFalse(element._rangesEqual(range1, range2));
+
+      assert.isTrue(element._rangesEqual(range1, Object.assign({}, range1)));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
index 6a12118..f300992 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
@@ -65,6 +65,7 @@
             show-actions="[[_showActions]]"
             comment-side="[[comment.__commentSide]]"
             side="[[comment.side]]"
+            root-id="[[rootId]]"
             project-config="[[_projectConfig]]"
             on-create-fix-comment="_handleCommentFix"
             on-comment-discard="_handleCommentDiscard"></gr-diff-comment>
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 d019c62..4de073e 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
@@ -32,7 +32,7 @@
         type: Array,
         value() { return []; },
       },
-      locationRange: String,
+      range: Object,
       keyEventTarget: {
         type: Object,
         value() { return document.body; },
@@ -57,6 +57,7 @@
         type: Number,
         value: null,
       },
+      rootId: String,
       unresolved: {
         type: Boolean,
         notify: true,
@@ -129,6 +130,7 @@
       if (this._orderedComments.length) {
         this._lastComment = this._getLastComment();
         this.unresolved = this._lastComment.unresolved;
+        this.hasDraft = this._lastComment.__draft;
       }
     },
 
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 bc8ddd0..514bc80 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
@@ -309,15 +309,17 @@
           </div>
         </div>
         <div class="actions robotActions" hidden$="[[!_showRobotActions]]">
-          <gr-endpoint-decorator name="robot-comment-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>
+          <template is="dom-if" if="[[isRobotComment]]">
+            <gr-endpoint-decorator name="robot-comment-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>
+          </template>
         </div>
       </div>
     </div>
@@ -331,6 +333,7 @@
       <gr-confirm-dialog
           id="confirmDiscardDialog"
           confirm-label="Discard"
+          confirm-on-enter
           on-confirm="_handleConfirmDiscard"
           on-cancel="_closeConfirmDiscardOverlay">
         <div class="header" slot="header">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
new file mode 100644
index 0000000..4c4e314
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.html
@@ -0,0 +1,58 @@
+<!--
+Copyright (C) 2018 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-diff-mode-selector">
+  <template>
+    <style include="shared-styles">
+      :host {
+        /* Used to remove horizontal whitespace between the icons. */
+        display: flex;
+      }
+      gr-button.selected iron-icon {
+        color: var(--color-link);
+      }
+      iron-icon {
+        height: 1.3rem;
+        width: 1.3rem;
+      }
+    </style>
+    <gr-button
+        id="sideBySideBtn"
+        link
+        has-tooltip
+        class$="[[_computeSelectedClass(mode, _VIEW_MODES.SIDE_BY_SIDE)]]"
+        title="Side-by-side diff"
+        on-tap="_handleSideBySideTap">
+      <iron-icon icon="gr-icons:side-by-side"></iron-icon>
+    </gr-button>
+    <gr-button
+        id="unifiedBtn"
+        link
+        has-tooltip
+        title="Unified diff"
+        class$="[[_computeSelectedClass(mode, _VIEW_MODES.UNIFIED)]]"
+        on-tap="_handleUnifiedTap">
+      <iron-icon icon="gr-icons:unified"></iron-icon>
+    </gr-button>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-diff-mode-selector.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
new file mode 100644
index 0000000..6c3a713
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
@@ -0,0 +1,68 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-mode-selector',
+
+    properties: {
+      mode: {
+        type: String,
+        notify: true,
+      },
+
+      /**
+       * If set to true, the user's preference will be updated every time a
+       * button is tapped. Don't set to true if there is no user.
+       */
+      saveOnChange: {
+        type: Boolean,
+        value: false,
+      },
+
+      /** @type {?} */
+      _VIEW_MODES: {
+        type: Object,
+        readOnly: true,
+        value: {
+          SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+          UNIFIED: 'UNIFIED_DIFF',
+        },
+      },
+    },
+
+    /**
+     * Set the mode. If save on change is enabled also update the preference.
+     */
+    setMode(newMode) {
+      if (this.saveOnChange && this.mode && this.mode !== newMode) {
+        this.$.restAPI.savePreferences({diff_view: newMode});
+      }
+      this.mode = newMode;
+    },
+
+    _computeSelectedClass(diffViewMode, buttonViewMode) {
+      return buttonViewMode === diffViewMode ? 'selected' : '';
+    },
+
+    _handleSideBySideTap() {
+      this.setMode(this._VIEW_MODES.SIDE_BY_SIDE);
+    },
+
+    _handleUnifiedTap() {
+      this.setMode(this._VIEW_MODES.UNIFIED);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
new file mode 100644
index 0000000..c791091
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
@@ -0,0 +1,84 @@
+<!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-diff-mode-selector</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="gr-diff-mode-selector.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-mode-selector></gr-diff-mode-selector>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-mode-selector tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_computeSelectedClass', () => {
+      assert.equal(
+          element._computeSelectedClass('SIDE_BY_SIDE', 'SIDE_BY_SIDE'),
+          'selected');
+      assert.equal(
+          element._computeSelectedClass('SIDE_BY_SIDE', 'UNIFIED_DIFF'), '');
+    });
+
+    test('setMode', () => {
+      const saveStub = sandbox.stub(element.$.restAPI, 'savePreferences');
+
+      // Setting the mode initially does not save prefs.
+      element.saveOnChange = true;
+      element.setMode('SIDE_BY_SIDE');
+      assert.isFalse(saveStub.called);
+
+      // Setting the mode to itself does not save prefs.
+      element.setMode('SIDE_BY_SIDE');
+      assert.isFalse(saveStub.called);
+
+      // Setting the mode to something else does not save prefs if saveOnChange
+      // is false.
+      element.saveOnChange = false;
+      element.setMode('UNIFIED_DIFF');
+      assert.isFalse(saveStub.called);
+
+      // Setting the mode to something else does not save prefs if saveOnChange
+      // is false.
+      element.saveOnChange = true;
+      element.setMode('SIDE_BY_SIDE');
+      assert.isTrue(saveStub.calledOnce);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
index ab2077d..9b830cf 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
@@ -76,7 +76,6 @@
         margin-left: 1em;
       }
     </style>
-
     <gr-overlay id="prefsOverlay" with-backdrop>
       <div class="header">
         Diff View Preferences
@@ -141,6 +140,14 @@
           <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
               on-tap="_handleSyntaxHighlightTap">
         </div>
+        <div class="pref">
+          <label for="automaticReviewInput">Automatically mark viewed files reviewed</label>
+          <input
+              is="iron-input"
+              id="automaticReviewInput"
+              type="checkbox"
+              on-tap="_handleAutomaticReviewTap">
+        </div>
       </div>
       <div class="actions">
         <gr-button id="cancelButton" link on-tap="_handleCancel">
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 185b047..ccc5361 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
@@ -63,6 +63,7 @@
       this.$.showTrailingWhitespaceInput.checked = prefs.show_whitespace_errors;
       this.$.lineWrappingInput.checked = prefs.line_wrapping;
       this.$.syntaxHighlightInput.checked = prefs.syntax_highlighting;
+      this.$.automaticReviewInput.checked = !prefs.manual_review;
     },
 
     _localPrefsChanged(changeRecord) {
@@ -93,6 +94,10 @@
       this.set('_newPrefs.line_wrapping', Polymer.dom(e).rootTarget.checked);
     },
 
+    _handleAutomaticReviewTap(e) {
+      this.set('_newPrefs.manual_review', !Polymer.dom(e).rootTarget.checked);
+    },
+
     _handleSave(e) {
       e.stopPropagation();
       this.prefs = this._newPrefs;
@@ -115,11 +120,6 @@
       this.$.prefsOverlay.close();
     },
 
-    _handlePrefsTap(e) {
-      e.preventDefault();
-      this._openPrefs();
-    },
-
     open() {
       this.$.prefsOverlay.open().then(() => {
         const focusStops = this.getFocusStops();
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 107ae2f..36ae32a 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
@@ -31,6 +31,7 @@
 <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-mode-selector/gr-diff-mode-selector.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">
@@ -132,6 +133,17 @@
         align-items: center;
         display: flex;
       }
+      .diffModeSelector {
+        align-items: center;
+        display: flex;
+      }
+      .diffModeSelector span {
+        margin-right: .2rem;
+      }
+      .diffModeSelector.hide,
+      .separator.hide {
+        display: none;
+      }
       gr-dropdown-list {
         --trigger-style: {
           text-transform: none;
@@ -259,19 +271,17 @@
           </span>
         </div>
         <div class="rightControls">
-          <gr-select
-              id="modeSelect"
-              bind-value="{{changeViewState.diffMode}}"
-              hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">
-            <select>
-              <option value="SIDE_BY_SIDE">Side By Side</option>
-              <option value="UNIFIED_DIFF">Unified</option>
-            </select>
-          </gr-select>
+          <div class$="diffModeSelector [[_computeModeSelectHideClass(_isImageDiff)]]">
+            <span>Diff view:</span>
+            <gr-diff-mode-selector
+                id="modeSelect"
+                save-on-change="[[_loggedIn]]"
+                mode="{{changeViewState.diffMode}}"></gr-diff-mode-selector>
+          </div>
           <span id="diffPrefsContainer"
               hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]" hidden>
             <span class="preferences desktop">
-              <span class="separator" hidden$="[[_computeModeSelectHidden(_isImageDiff)]]"></span>
+              <span class$="separator [[_computeModeSelectHideClass(_isImageDiff)]]"></span>
               <gr-button link
                   class="prefsButton"
                   on-tap="_handlePrefsTap">Preferences</gr-button>
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 b4dd50a..cd06174 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
@@ -25,6 +25,11 @@
     RIGHT: 'right',
   };
 
+  const DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
   Polymer({
     is: 'gr-diff-view',
 
@@ -169,7 +174,7 @@
     observers: [
       '_getProjectConfig(_change.project)',
       '_getFiles(_changeNum, _patchRange.*)',
-      '_setReviewedObserver(_loggedIn, params.*)',
+      '_setReviewedObserver(_loggedIn, params.*, _prefs)',
     ],
 
     keyBindings: {
@@ -186,7 +191,8 @@
       'a shift+a': '_handleAKey',
       'u': '_handleUKey',
       ',': '_handleCommaKey',
-      'v': '_handleVKey',
+      'm': '_handleMKey',
+      'r': '_handleRKey',
     },
 
     attached() {
@@ -261,6 +267,14 @@
           this._patchRange.patchNum, this._path, reviewed);
     },
 
+    _handleRKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._setReviewed(!this.$.reviewed.checked);
+    },
+
     _handleEscKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
@@ -426,15 +440,15 @@
       this.$.diffPreferences.open();
     },
 
-    _handleVKey(e) {
+    _handleMKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      if (this.changeViewState.diffMode=='SIDE_BY_SIDE') {
-        this.set('changeViewState.diffMode', 'UNIFIED_DIFF');
+      if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
       } else {
-        this.set('changeViewState.diffMode', 'SIDE_BY_SIDE');
+        this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
       }
     },
 
@@ -615,9 +629,11 @@
       }
     },
 
-    _setReviewedObserver(_loggedIn, paramsRecord) {
+    _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
       const params = paramsRecord.base || {};
-      if (_loggedIn && params.view === Gerrit.Nav.View.DIFF) {
+      if (!_loggedIn || _prefs.manual_review) { return; }
+
+      if (params.view === Gerrit.Nav.View.DIFF) {
         this._setReviewed(true);
       }
     },
@@ -807,15 +823,15 @@
       if (this.changeViewState.diffMode) {
         return this.changeViewState.diffMode;
       } else if (this._userPrefs) {
-        return this.changeViewState.diffMode =
-            this._userPrefs.default_diff_view;
+        this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
+        return this._userPrefs.default_diff_view;
       } else {
         return 'SIDE_BY_SIDE';
       }
     },
 
-    _computeModeSelectHidden() {
-      return this._isImageDiff;
+    _computeModeSelectHideClass(isImageDiff) {
+      return isImageDiff ? 'hide' : '';
     },
 
     _onLineSelected(e, detail) {
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 6cb92e7..6dff570 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
@@ -158,6 +158,15 @@
       MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
           false, 'SIDE_BY_SIDE', false));
+
+      sandbox.stub(element, '_setReviewed');
+      element.$.reviewed.checked = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      assert.isFalse(element._setReviewed.called);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      assert.isTrue(element._setReviewed.called);
+      assert.equal(element._setReviewed.lastCall.args[0], true);
     });
 
     test('keyboard shortcuts with patch range', () => {
@@ -509,6 +518,29 @@
       assert.isTrue(link.hasAttribute('download'));
     });
 
+    test('_prefs.manual_review is respected', () => {
+      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
+          () => Promise.resolve());
+      sandbox.stub(element.$.diff, 'reload');
+
+      element._loggedIn = true;
+      element.params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+      };
+      element._prefs = {manual_review: true};
+      flushAsynchronousOperations();
+
+      assert.isFalse(saveReviewedStub.called);
+      element._prefs = {};
+      flushAsynchronousOperations();
+
+      assert.isTrue(saveReviewedStub.called);
+    });
+
     test('file review status', () => {
       const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
           () => Promise.resolve());
@@ -522,6 +554,7 @@
         basePatchNum: '1',
         path: '/COMMIT_MSG',
       };
+      element._prefs = {};
       flushAsynchronousOperations();
 
       const commitMsg = Polymer.dom(element.root).querySelector(
@@ -582,21 +615,21 @@
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
 
       // The mode selected in the view state reflects the selected option.
-      assert.equal(element._getDiffViewMode(), select.nativeSelect.value);
+      assert.equal(element._getDiffViewMode(), select.mode);
 
       // The mode selected in the view state reflects the view rednered in the
       // diff.
-      assert.equal(select.nativeSelect.value, diffDisplay.viewMode);
+      assert.equal(select.mode, diffDisplay.viewMode);
 
       // We will simulate a user change of the selected mode.
       const newMode = 'UNIFIED_DIFF';
-      // Set the actual value of the select, and simulate the change event.
-      select.nativeSelect.value = newMode;
-      element.fire('change', {}, {node: select.nativeSelect});
+
+      // Set the mode, and simulate the change event.
+      element.set('changeViewState.diffMode', newMode);
 
       // Make sure the handler was called and the state is still coherent.
       assert.equal(element._getDiffViewMode(), newMode);
-      assert.equal(element._getDiffViewMode(), select.nativeSelect.value);
+      assert.equal(element._getDiffViewMode(), select.mode);
       assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
     });
 
@@ -609,17 +642,16 @@
 
       // Attach a new gr-diff-view so we can intercept the preferences fetch.
       const view = document.createElement('gr-diff-view');
-      const select = view.$.modeSelect;
       fixture('blank').appendChild(view);
       flushAsynchronousOperations();
 
       // At this point the diff mode doesn't yet have the user's preference.
-      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
+      assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
 
       // Receive the overriding preference.
       resolvePrefs({default_diff_view: 'UNIFIED'});
       flushAsynchronousOperations();
-      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
     suite('_commitRange', () => {
@@ -774,6 +806,19 @@
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
+    test('_handleMKey', () => {
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const e = {preventDefault: () => {}};
+      // Initial state.
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      element._handleMKey(e);
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+
+      element._handleMKey(e);
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
     suite('_loadComments', () => {
       test('empty', done => {
         element._loadComments().then(() => {
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 59acafe..a019664 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -17,6 +17,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.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">
@@ -81,10 +82,6 @@
       .diff-row.target-row.target-side-right .lineNum.right,
       .diff-row.target-row.unified .lineNum {
         background-color: #BBDEFB;
-      }
-      .diff-row.target-row.target-side-left .lineNum.left:before,
-      .diff-row.target-row.target-side-right .lineNum.right:before,
-      .diff-row.target-row.unified .lineNum:before {
         color: #000;
       }
       .blank,
@@ -105,22 +102,20 @@
         vertical-align: top;
         white-space: pre;
       }
-      .contextLineNum:before,
-      .lineNum:before {
-        box-sizing: border-box;
-        display: inline-block;
+      .contextLineNum,
+      .lineNum {
+        -webkit-user-select: none;
+        -moz-user-select: none;
+        -ms-user-select: none;
+        user-select: none;
+
         color: #666;
-        content: attr(data-value);
         padding: 0 .5em;
         text-align: right;
-        width: 100%;
       }
-      .canComment .lineNum[data-value] {
+      .canComment .lineNum {
         cursor: pointer;
       }
-      .canComment .lineNum[data-value="FILE"]:before {
-        content: 'File';
-      }
       .content {
         overflow: hidden;
         /* Set max and min width since setting width on table cells still
@@ -295,6 +290,7 @@
       </gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-reporting id="reporting" category="diff"></gr-reporting>
   </template>
   <script src="gr-diff-line.js"></script>
   <script src="gr-diff-group.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 4ad35fc..5c870f2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -15,6 +15,8 @@
   'use strict';
 
   const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
+  const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
+      'of an edit.';
   const ERR_INVALID_LINE = 'Invalid line number: ';
   const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -182,7 +184,7 @@
 
     /** @return {!Promise} */
     reload() {
-      this.$.diffBuilder.cancel();
+      this.cancel();
       this.clearBlame();
       this._safetyBypass = null;
       this._showWarning = false;
@@ -203,6 +205,11 @@
       });
     },
 
+    /** Cancel any remaining diff builder rendering work. */
+    cancel() {
+      this.$.diffBuilder.cancel();
+    },
+
     /** @return {!Array<!HTMLElement>} */
     getCursorStops() {
       if (this.hidden && this.noAutoRender) {
@@ -353,9 +360,16 @@
             this.patchRange.basePatchNum :
             this.patchRange.patchNum;
 
-        if (this.patchNumEquals(patchNum, this.EDIT_NAME)) {
+        const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
+        const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
+            this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
+
+        if (isEdit) {
           this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
           return false;
+        } else if (isEditBase) {
+          this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
+          return false;
         }
         return true;
       });
@@ -375,13 +389,21 @@
       const patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
       const isOnParent =
         this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      const threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
+      const threadEl = this._getOrCreateThread(contentEl, patchNum,
           side, isOnParent, opt_range);
       threadEl.addOrEditDraft(opt_lineNum, opt_range);
     },
 
-    _getThreadForRange(threadGroupEl, rangeToCheck) {
-      return threadGroupEl.getThreadForRange(rangeToCheck);
+    /**
+     * Fetch the thread group at the given range, or the range-less thread
+     * on the line if no range is provided.
+     *
+     * @param {!Object} threadGroupEl
+     * @param {!Object=} opt_range
+     * @return {!Object}
+     */
+    _getThread(threadGroupEl, opt_range) {
+      return threadGroupEl.getThread(opt_range);
     },
 
     _getThreadGroupForLine(contentEl) {
@@ -403,31 +425,32 @@
     },
 
     /**
+     * Gets or creates a comment thread for a specific spot on a diff.
+     * May include a range, if the comment is a range comment.
+     *
      * @param {!Object} contentEl
      * @param {number} patchNum
      * @param {string} commentSide
      * @param {boolean} isOnParent
      * @param {!Object=} opt_range
+     * @return {!Object}
      */
-    _getOrCreateThreadAtLineRange(contentEl, patchNum, commentSide,
+    _getOrCreateThread(contentEl, patchNum, commentSide,
         isOnParent, opt_range) {
-      const rangeToCheck = this._getRangeString(commentSide, opt_range);
-
       // Check if thread group exists.
       let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
         threadGroupEl = this.$.diffBuilder.createCommentThreadGroup(
-            this.changeNum, patchNum, this.path, isOnParent);
+            this.changeNum, patchNum, this.path, isOnParent, commentSide);
         contentEl.appendChild(threadGroupEl);
       }
 
-      let threadEl = this._getThreadForRange(threadGroupEl, rangeToCheck);
+      let threadEl = this._getThread(threadGroupEl, opt_range);
 
       if (!threadEl) {
-        threadGroupEl.addNewThread(rangeToCheck, commentSide);
+        threadGroupEl.addNewThread(opt_range);
         Polymer.dom.flush();
-        threadEl = this._getThreadForRange(threadGroupEl, rangeToCheck);
-        threadEl.commentSide = commentSide;
+        threadEl = this._getThread(threadGroupEl, opt_range);
       }
       return threadEl;
     },
@@ -481,7 +504,7 @@
 
     _handleThreadDiscard(e) {
       const el = Polymer.dom(e).rootTarget;
-      el.parentNode.removeThread(el.locationRange);
+      el.parentNode.removeThread(el.rootId);
     },
 
     _handleCommentDiscard(e) {
@@ -647,6 +670,7 @@
           this.patchRange.patchNum,
           this.path,
           this._handleGetDiffError.bind(this)).then(diff => {
+            this._reportDiff(diff);
             if (!this.commitRange) {
               this.filesWeblinks = {};
               return diff;
@@ -663,6 +687,38 @@
           });
     },
 
+    /**
+     * Report info about the diff response.
+     */
+    _reportDiff(diff) {
+      if (!diff || !diff.content) { return; }
+
+      // Count the delta lines stemming from normal deltas, and from
+      // due_to_rebase deltas.
+      let nonRebaseDelta = 0;
+      let rebaseDelta = 0;
+      diff.content.forEach(chunk => {
+        if (chunk.ab) { return; }
+        const deltaSize = Math.max(
+            chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
+        if (chunk.due_to_rebase) {
+          rebaseDelta += deltaSize;
+        } else {
+          nonRebaseDelta += deltaSize;
+        }
+      });
+
+      // Find the percent of the delta from due_to_rebase chunks rounded to two
+      // digits. Diffs with no delta are considered 0%.
+      const totalDelta = rebaseDelta + nonRebaseDelta;
+      const percentRebaseDelta = !totalDelta ? 0 :
+          Math.round(100 * rebaseDelta / totalDelta);
+
+      // Report the percentage in the "diff" category.
+      this.$.reporting.reportInteraction('rebase-delta-percent',
+          percentRebaseDelta);
+    },
+
     /** @return {!Promise} */
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
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 5b3670b..1f06dd5 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
@@ -49,7 +49,7 @@
 
     test('reload cancels before network resolves', () => {
       element = fixture('basic');
-      const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
+      const cancelStub = sandbox.stub(element, 'cancel');
 
       // Stub the network calls into requests that never resolve.
       sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
@@ -58,13 +58,18 @@
       assert.isTrue(cancelStub.called);
     });
 
+    test('cancel', () => {
+      const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
+      element.cancel();
+      assert.isTrue(cancelStub.calledOnce);
+    });
+
     test('_diffLength', () => {
       element = fixture('basic');
       const mock = document.createElement('mock-diff-response');
       assert.equal(element._diffLength(mock.diffResponse), 52);
     });
 
-
     suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
       let lineEl;
       let contentEl;
@@ -191,7 +196,7 @@
 
       test('loads files weblinks', () => {
         const weblinksStub = sandbox.stub(Gerrit.Nav, '_generateWeblinks')
-        .returns({name: 'stubb', url: '#s'});
+            .returns({name: 'stubb', url: '#s'});
         sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({}));
         element.projectName = 'test-project';
         element.path = 'test-path';
@@ -353,18 +358,15 @@
         element.patchRange = {basePatchNum: 1, patchNum: 2};
         element.path = 'file.txt';
 
-        sandbox.stub(element.$.diffBuilder, 'createCommentThreadGroup', () => {
-          const threadGroup =
-          document.createElement('gr-diff-comment-thread-group');
-          threadGroup.patchForNewThreads = 1;
-          return threadGroup;
-        });
+        const mock = document.createElement('mock-diff-response');
+        element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
+            mock.diffResponse, {}, {tab_size: 2, line_length: 80});
 
         // No thread groups.
         assert.isNotOk(element._getThreadGroupForLine(contentEl));
 
         // A thread group gets created.
-        assert.isOk(element._getOrCreateThreadAtLineRange(contentEl,
+        assert.isOk(element._getOrCreateThread(contentEl,
             patchNum, commentSide, side));
 
         // Try to fetch a thread with a different range.
@@ -375,7 +377,7 @@
           endChar: 3,
         };
 
-        assert.isOk(element._getOrCreateThreadAtLineRange(
+        assert.isOk(element._getOrCreateThread(
             contentEl, patchNum, commentSide, side, range));
         // The new thread group can be fetched.
         assert.isOk(element._getThreadGroupForLine(contentEl));
@@ -833,6 +835,7 @@
           getPreferences() {
             return Promise.resolve({time_format: 'HHMM_12'});
           },
+          getAccountCapabilities() { return Promise.resolve(); },
         });
         element = fixture('basic');
         element.patchRange = {};
@@ -876,6 +879,24 @@
         });
       });
 
+      test('addDraftAtLine on an edit base', done => {
+        element.patchRange.patchNum = element.EDIT_NAME;
+        element.patchRange.basePatchNum = element.PARENT_NAME;
+        sandbox.stub(element, '_selectLine');
+        sandbox.stub(element, '_createComment');
+        const loggedInErrorSpy = sandbox.spy();
+        const alertSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addEventListener('show-alert', alertSpy);
+        element.addDraftAtLine(fakeLineEl);
+        flush(() => {
+          assert.isFalse(loggedInErrorSpy.called);
+          assert.isTrue(alertSpy.called);
+          assert.isFalse(element._createComment.called);
+          done();
+        });
+      });
+
       suite('change in preferences', () => {
         setup(() => {
           element._diff = {
@@ -1112,6 +1133,81 @@
             });
       });
     });
+
+    suite('_reportDiff', () => {
+      let reportStub;
+
+      setup(() => {
+        element = fixture('basic');
+        reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
+      });
+
+      test('null and content-less', () => {
+        element._reportDiff(null);
+        assert.isFalse(reportStub.called);
+
+        element._reportDiff({});
+        assert.isFalse(reportStub.called);
+      });
+
+      test('diff w/ no delta', () => {
+        const diff = {
+          content: [
+            {ab: ['foo', 'bar']},
+            {ab: ['baz', 'foo']},
+          ],
+        };
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.strictEqual(reportStub.lastCall.args[1], 0);
+      });
+
+      test('diff w/ no rebase delta', () => {
+        const diff = {
+          content: [
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo']},
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo'], b: ['bar', 'baz']},
+            {ab: ['foo', 'bar']},
+            {b: ['baz', 'foo']},
+            {ab: ['foo', 'bar']},
+          ],
+        };
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.strictEqual(reportStub.lastCall.args[1], 0);
+      });
+
+      test('diff w/ some rebase delta', () => {
+        const diff = {
+          content: [
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo'], due_to_rebase: true},
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo'], b: ['bar', 'baz']},
+            {ab: ['foo', 'bar']},
+            {b: ['baz', 'foo'], due_to_rebase: true},
+            {ab: ['foo', 'bar']},
+            {a: ['baz', 'foo']},
+          ],
+        };
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.strictEqual(reportStub.lastCall.args[1], 50);
+      });
+
+      test('diff w/ all rebase delta', () => {
+        const diff = {content: [{
+          a: ['foo', 'bar'],
+          b: ['baz', 'foo'],
+          due_to_rebase: true,
+        }]};
+        element._reportDiff(diff);
+        assert.isTrue(reportStub.calledOnce);
+        assert.strictEqual(reportStub.lastCall.args[1], 100);
+      });
+    });
   });
 
   a11ySuite('basic');
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.html b/polygerrit-ui/app/elements/edit/gr-edit-constants.html
index 1941dc8..4bae900 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.html
@@ -19,8 +19,9 @@
 
     const GrEditConstants = window.GrEditConstants || {};
 
+    // Order corresponds to order in the UI.
     GrEditConstants.Actions = {
-      EDIT: {label: 'Edit', id: 'edit'},
+      OPEN: {label: 'Open', id: 'open'},
       DELETE: {label: 'Delete', id: 'delete'},
       RENAME: {label: 'Rename', id: 'rename'},
       RESTORE: {label: 'Restore', id: 'restore'},
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
index 5a59a37..2acbab5 100644
--- 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
@@ -69,6 +69,11 @@
         padding: 0 .15em;
         width: 100%;
       }
+      @media screen and (max-width: 50em) {
+        gr-confirm-dialog {
+          width: 100vw;
+        }
+      }
     </style>
     <template is="dom-repeat" items="[[_actions]]" as="action">
       <gr-button
@@ -79,13 +84,16 @@
     </template>
     <gr-overlay id="overlay" with-backdrop>
       <gr-confirm-dialog
-          id="editDialog"
+          id="openDialog"
           class="invisible dialog"
           disabled$="[[!_isValidPath(_path)]]"
-          confirm-label="Edit"
-          on-confirm="_handleEditConfirm"
+          confirm-label="Open"
+          confirm-on-enter
+          on-confirm="_handleOpenConfirm"
           on-cancel="_handleDialogCancel">
-        <div class="header" slot="header">Edit a file</div>
+        <div class="header" slot="header">
+          Open an existing or new file
+        </div>
         <div class="main" slot="main">
           <gr-autocomplete
               placeholder="Enter an existing or new full file path."
@@ -98,9 +106,10 @@
           class="invisible dialog"
           disabled$="[[!_isValidPath(_path)]]"
           confirm-label="Delete"
+          confirm-on-enter
           on-confirm="_handleDeleteConfirm"
           on-cancel="_handleDialogCancel">
-        <div class="header" slot="header">Delete a file</div>
+        <div class="header" slot="header">Delete a file from the repo</div>
         <div class="main" slot="main">
           <gr-autocomplete
               placeholder="Enter an existing full file path."
@@ -113,9 +122,10 @@
           class="invisible dialog"
           disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
           confirm-label="Rename"
+          confirm-on-enter
           on-confirm="_handleRenameConfirm"
           on-cancel="_handleDialogCancel">
-        <div class="header" slot="header">Rename a file</div>
+        <div class="header" slot="header">Rename a file in the repo</div>
         <div class="main" slot="main">
           <gr-autocomplete
               placeholder="Enter an existing full file path."
@@ -132,6 +142,7 @@
           id="restoreDialog"
           class="invisible dialog"
           confirm-label="Restore"
+          confirm-on-enter
           on-confirm="_handleRestoreConfirm"
           on-cancel="_handleDialogCancel">
         <div class="header" slot="header">Restore this file?</div>
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
index cffae06..e3b3494 100644
--- 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
@@ -59,8 +59,8 @@
       e.preventDefault();
       const action = Polymer.dom(e).localTarget.id;
       switch (action) {
-        case GrEditConstants.Actions.EDIT.id:
-          this.openEditDialog();
+        case GrEditConstants.Actions.OPEN.id:
+          this.openOpenDialog();
           return;
         case GrEditConstants.Actions.DELETE.id:
           this.openDeleteDialog();
@@ -74,21 +74,33 @@
       }
     },
 
-    openEditDialog(opt_path) {
+    /**
+     * @param {string=} opt_path
+     */
+    openOpenDialog(opt_path) {
       if (opt_path) { this._path = opt_path; }
-      return this._showDialog(this.$.editDialog);
+      return this._showDialog(this.$.openDialog);
     },
 
+    /**
+     * @param {string=} opt_path
+     */
     openDeleteDialog(opt_path) {
       if (opt_path) { this._path = opt_path; }
       return this._showDialog(this.$.deleteDialog);
     },
 
+    /**
+     * @param {string=} opt_path
+     */
     openRenameDialog(opt_path) {
       if (opt_path) { this._path = opt_path; }
       return this._showDialog(this.$.renameDialog);
     },
 
+    /**
+     * @param {string=} opt_path
+     */
     openRestoreDialog(opt_path) {
       if (opt_path) { this._path = opt_path; }
       return this._showDialog(this.$.restoreDialog);
@@ -111,7 +123,7 @@
     /**
      * Given a dom event, gets the dialog that lies along this event path.
      * @param {!Event} e
-     * @return {!Element}
+     * @return {!Element|undefined}
      */
     _getDialogFromEvent(e) {
       return Polymer.dom(e).path.find(element => {
@@ -121,6 +133,11 @@
     },
 
     _showDialog(dialog) {
+      // Some dialogs may not fire their on-close event when closed in certain
+      // ways (e.g. by clicking outside the dialog body). This call prevents
+      // multiple dialogs from being shown in the same overlay.
+      this._hideAllDialogs();
+
       return this.$.overlay.open().then(() => {
         dialog.classList.toggle('invisible', false);
         const autocomplete = dialog.querySelector('gr-autocomplete');
@@ -129,7 +146,18 @@
       });
     },
 
+    _hideAllDialogs() {
+      const dialogs = Polymer.dom(this.root).querySelectorAll('.dialog');
+      for (const dialog of dialogs) { this._closeDialog(dialog); }
+    },
+
+    /**
+     * @param {Element|undefined} dialog
+     * @param {boolean=} clearInputs
+     */
     _closeDialog(dialog, clearInputs) {
+      if (!dialog) { return; }
+
       if (clearInputs) {
         // Dialog may have autocompletes and plain inputs -- as these have
         // different properties representing their bound text, it is easier to
@@ -148,7 +176,7 @@
       this._closeDialog(this._getDialogFromEvent(e));
     },
 
-    _handleEditConfirm(e) {
+    _handleOpenConfirm(e) {
       const url = Gerrit.Nav.getEditUrlForDiff(this.change, this._path,
           this.patchNum);
       Gerrit.Nav.navigateToRelativeUrl(url);
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
index 5dcc581..bbb5111 100644
--- 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
@@ -45,6 +45,7 @@
     element.change = {_number: '42'};
     showDialogSpy = sandbox.spy(element, '_showDialog');
     closeDialogSpy = sandbox.spy(element, '_closeDialog');
+    sandbox.stub(element, '_hideAllDialogs');
     queryStub = sandbox.stub(element.$.restAPI, 'queryChangeFiles')
         .returns(Promise.resolve([]));
     flushAsynchronousOperations();
@@ -75,17 +76,18 @@
       assert.isTrue(element._isValidPath('test.js'));
     });
 
-    test('edit', () => {
-      MockInteractions.tap(element.$$('#edit'));
+    test('open', () => {
+      MockInteractions.tap(element.$$('#open'));
       element.patchNum = 1;
       return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.editDialog.disabled);
+        assert.isTrue(element._hideAllDialogs.called);
+        assert.isTrue(element.$.openDialog.disabled);
         assert.isFalse(queryStub.called);
-        element.$.editDialog.querySelector('gr-autocomplete').text =
+        element.$.openDialog.querySelector('gr-autocomplete').text =
             'src/test.cpp';
         assert.isTrue(queryStub.called);
-        assert.isFalse(element.$.editDialog.disabled);
-        MockInteractions.tap(element.$.editDialog.$$('gr-button[primary]'));
+        assert.isFalse(element.$.openDialog.disabled);
+        MockInteractions.tap(element.$.openDialog.$$('gr-button[primary]'));
         for (const stub of navStubs) { assert.isTrue(stub.called); }
         assert.deepEqual(Gerrit.Nav.getEditUrlForDiff.lastCall.args,
             [element.change, 'src/test.cpp', element.patchNum]);
@@ -94,13 +96,13 @@
     });
 
     test('cancel', () => {
-      MockInteractions.tap(element.$$('#edit'));
+      MockInteractions.tap(element.$$('#open'));
       return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.editDialog.disabled);
-        element.$.editDialog.querySelector('gr-autocomplete').text =
+        assert.isTrue(element.$.openDialog.disabled);
+        element.$.openDialog.querySelector('gr-autocomplete').text =
             'src/test.cpp';
-        assert.isFalse(element.$.editDialog.disabled);
-        MockInteractions.tap(element.$.editDialog.$$('gr-button'));
+        assert.isFalse(element.$.openDialog.disabled);
+        MockInteractions.tap(element.$.openDialog.$$('gr-button'));
         for (const stub of navStubs) { assert.isFalse(stub.called); }
         assert.isTrue(closeDialogSpy.called);
         assert.equal(element._path, 'src/test.cpp');
@@ -319,10 +321,10 @@
     });
   });
 
-  test('openEditDialog', () => {
-    return element.openEditDialog('test/path.cpp').then(() => {
-      assert.isFalse(element.$.editDialog.hasAttribute('hidden'));
-      assert.equal(element.$.editDialog.querySelector('gr-autocomplete').text,
+  test('openOpenDialog', () => {
+    return element.openOpenDialog('test/path.cpp').then(() => {
+      assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
+      assert.equal(element.$.openDialog.querySelector('gr-autocomplete').text,
           'test/path.cpp');
     });
   });
@@ -331,9 +333,9 @@
     const spy = sandbox.spy(element, '_getDialogFromEvent');
     element.addEventListener('tap', element._getDialogFromEvent);
 
-    MockInteractions.tap(element.$.editDialog);
+    MockInteractions.tap(element.$.openDialog);
     flushAsynchronousOperations();
-    assert.equal(spy.lastCall.returnValue.id, 'editDialog');
+    assert.equal(spy.lastCall.returnValue.id, 'openDialog');
 
     MockInteractions.tap(element.$.deleteDialog);
     flushAsynchronousOperations();
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
index 96ba196b..f46f414 100644
--- 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
@@ -30,11 +30,7 @@
         display: flex;
         justify-content: flex-end;
       }
-      #edit {
-        text-decoration: none;
-      }
-      #edit,
-      #more {
+      #actions {
         margin-right: 1em;
       }
       gr-button,
@@ -53,18 +49,13 @@
         }
       }
     </style>
-    <gr-button
-        id="edit"
-        link
-        on-tap="_handleEditTap">Edit</gr-button>
-    <!-- TODO(kaspern): implement more menu. -->
     <gr-dropdown
-        id="more"
+        id="actions"
         items="[[_fileActions]]"
         down-arrow
         vertical-offset="20"
         on-tap-item="_handleActionTap"
-        link>More</gr-dropdown>
+        link>Actions</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
index 62a4785..6a0ccf5 100644
--- 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
@@ -25,11 +25,9 @@
 
     properties: {
       filePath: String,
-      // Edit action not needed in the overflow.
       _allFileActions: {
         type: Array,
-        value: () => Object.values(GrEditConstants.Actions)
-            .filter(action => action !== GrEditConstants.Actions.EDIT),
+        value: () => Object.values(GrEditConstants.Actions),
       },
       _fileActions: {
         type: Array,
@@ -37,12 +35,6 @@
       },
     },
 
-    _handleEditTap(e) {
-      e.preventDefault();
-      e.stopPropagation();
-      this._dispatchFileAction(GrEditConstants.Actions.EDIT.id, this.filePath);
-    },
-
     _handleActionTap(e) {
       e.preventDefault();
       e.stopPropagation();
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
index 42fb466..27b28fa 100644
--- 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
@@ -47,55 +47,56 @@
 
   teardown(() => { sandbox.restore(); });
 
-  test('edit tap emits event', () => {
+  test('open tap emits event', () => {
+    const actions = element.$.actions;
     element.filePath = 'foo';
+    actions._open();
+    flushAsynchronousOperations();
 
-    MockInteractions.tap(element.$.edit);
+    MockInteractions.tap(actions.$$('li [data-id="open"]'));
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.EDIT.id, path: 'foo'});
+        {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
   });
 
   test('delete tap emits event', () => {
-    const more = element.$.more;
+    const actions = element.$.actions;
     element.filePath = 'foo';
-    more._open();
+    actions._open();
     flushAsynchronousOperations();
 
-    MockInteractions.tap(more.$$('li [data-id="delete"]'));
+    MockInteractions.tap(actions.$$('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;
+    const actions = element.$.actions;
     element.filePath = 'foo';
-    more._open();
+    actions._open();
     flushAsynchronousOperations();
 
-    MockInteractions.tap(more.$$('li [data-id="restore"]'));
+    MockInteractions.tap(actions.$$('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;
+    const actions = element.$.actions;
     element.filePath = 'foo';
-    more._open();
+    actions._open();
     flushAsynchronousOperations();
 
-    MockInteractions.tap(more.$$('li [data-id="rename"]'));
+    MockInteractions.tap(actions.$$('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));
+    assert.equal(element._allFileActions.length, 4);
   });
 });
 </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
index ac8ec08..8f2f166 100644
--- 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
@@ -36,8 +36,8 @@
         background-color: var(--view-background-color);
       }
       gr-fixed-panel {
-        background-color: #fff;
-        border-bottom: 1px #eee solid;
+        background-color: #ebf5fb;
+        border-bottom: 1px #ddd solid;
         z-index: 1;
       }
       header,
@@ -48,7 +48,7 @@
         padding: .75em var(--default-horizontal-margin);
       }
       header gr-editable-label {
-        font-size: 1.2rem;
+        font-size: var(--font-size-large);
         font-weight: bold;
         --label-style: {
           text-overflow: initial;
@@ -67,6 +67,11 @@
       .textareaWrapper .editButtons {
         display: none;
       }
+      .controlGroup {
+        align-items: center;
+        display: flex;
+        font-size: var(--font-size-large);
+      }
       .rightControls {
         justify-content: flex-end;
       }
@@ -82,12 +87,16 @@
     </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">
+        <span class="controlGroup">
+          <span>Edit mode</span>
+          <span class="separator"></span>
+          <gr-editable-label
+              label-text="File path"
+              value="[[_path]]"
+              placeholder="File path..."
+              on-changed="_handlePathChanged"></gr-editable-label>
+        </span>
+        <span class="controlGroup rightControls">
           <gr-button
               id="close"
               link
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
index 866a62b..bd07375 100644
--- 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
@@ -135,17 +135,24 @@
 
     _viewEditInChangeView() {
       const patch = this._successfulSave ? this.EDIT_NAME : this._patchNum;
-      Gerrit.Nav.navigateToChange(this._change, patch);
+      Gerrit.Nav.navigateToChange(this._change, patch, null,
+          patch !== this.EDIT_NAME);
     },
 
     _getFileData(changeNum, path, patchNum) {
       return this.$.restAPI.getFileContent(changeNum, path, patchNum)
           .then(res => {
-            if (!res.ok) { return; }
-
-            this._type = res.type || '';
             this._newContent = res.content || '';
             this._content = res.content || '';
+
+            // A non-ok response may result if the file does not yet exist.
+            // The `type` field of the response is only valid when the file
+            // already exists.
+            if (res.ok && res.type) {
+              this._type = res.type;
+            } else {
+              this._type = '';
+            }
           });
     },
 
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
index 18ff1f5..ea87f56 100644
--- 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
@@ -242,9 +242,9 @@
 
       // Ensure no data is set with a bad response.
       return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, 'initial');
-        assert.equal(element._content, 'initial');
-        assert.equal(element._type, 'initial');
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, '');
       });
     });
 
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 68b9b59..46f4649 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -78,13 +78,14 @@
       footer {
         color: var(--primary-text-color);
       }
-      gr-main-header.shadow {
-        /* Make it obvious for shadow dom testing */
-        border-bottom: 1px solid pink;
-      }
       gr-main-header {
         background-color: var(--header-background-color);
         padding: 0 var(--default-horizontal-margin);
+        border-bottom: 1px solid #ddd;
+      }
+      gr-main-header.shadow {
+        /* Make it obvious for shadow dom testing */
+        border-bottom: 1px solid pink;
       }
       footer {
         background-color: var(--footer-background-color);
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
index da9cae6..00735cf 100644
--- 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
@@ -82,16 +82,6 @@
         </span>
       </section>
       <section>
-        <span class="title">Hide line numbers</span>
-        <span class="value">
-          <input
-              id="hideLineNumbers"
-              type="checkbox"
-              checked$="[[editPrefs.hide_line_numbers]]"
-              on-change="_handleLineNumbersChanged">
-        </span>
-      </section>
-      <section>
         <span class="title">Match brackets</span>
         <span class="value">
           <input
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
index 5a97363..dbacfda 100644
--- 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
@@ -49,11 +49,6 @@
       this._handleEditPrefsChanged();
     },
 
-    _handleLineNumbersChanged() {
-      this.set('editPrefs.hide_line_numbers', this.$.hideLineNumbers.checked);
-      this._handleEditPrefsChanged();
-    },
-
     _handleMatchBracketsChanged() {
       this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
       this._handleEditPrefsChanged();
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
index a0f95c1..e91290f6 100644
--- 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
@@ -93,8 +93,6 @@
           .firstElementChild.checked, editPreferences.syntax_highlighting);
       assert.equal(valueOf('Show tabs', 'editPreferences')
           .firstElementChild.checked, editPreferences.show_tabs);
-      assert.equal(valueOf('Hide 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')
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
new file mode 100644
index 0000000..b38509d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-gpg-editor">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      .statusHeader {
+        width: 4em;
+      }
+      .keyHeader {
+        width: 9em;
+      }
+      .userIdHeader {
+        width: 15em;
+      }
+      #viewKeyOverlay {
+        padding: 2em;
+        width: 50em;
+      }
+      .publicKey {
+        font-family: var(--monospace-font-family);
+        overflow-x: scroll;
+        overflow-wrap: break-word;
+        width: 30em;
+      }
+      .closeButton {
+        bottom: 2em;
+        position: absolute;
+        right: 2em;
+      }
+      #existing {
+        margin-bottom: 1em;
+      }
+      #existing .commentColumn {
+        min-width: 27em;
+        width: auto;
+      }
+    </style>
+    <div class="gr-form-styles">
+      <fieldset id="existing">
+        <table>
+          <thead>
+            <tr>
+              <th class="idColumn">ID</th>
+              <th class="fingerPrintColumn">Fingerprint</th>
+              <th class="userIdHeader">User IDs</th>
+              <th class="keyHeader">Public Key</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <template is="dom-repeat" items="[[_keys]]" as="key">
+              <tr>
+                <td class="idColumn">[[key.id]]</td>
+                <td class="fingerPrintColumn">[[key.fingerprint]]</td>
+                <td class="userIdHeader">
+                  <template is="dom-repeat" items="[[key.user_ids]]">
+                    [[item]]
+                  </template>
+                </td>
+                <td class="keyHeader">
+                  <gr-button
+                      on-tap="_showKey"
+                      data-index$="[[index]]"
+                      link>Click to View</gr-button>
+                </td>
+                <td>
+                  <gr-button
+                      data-index$="[[index]]"
+                      on-tap="_handleDeleteKey">Delete</gr-button>
+                </td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+        <gr-overlay id="viewKeyOverlay" with-backdrop>
+          <fieldset>
+            <section>
+              <span class="title">Status</span>
+              <span class="value">[[_keyToView.status]]</span>
+            </section>
+            <section>
+              <span class="title">Key</span>
+              <span class="value">[[_keyToView.key]]</span>
+            </section>
+          </fieldset>
+          <gr-button
+              class="closeButton"
+              on-tap="_closeOverlay">Close</gr-button>
+        </gr-overlay>
+        <gr-button
+            on-tap="save"
+            disabled$="[[!hasUnsavedChanges]]">Save changes</gr-button>
+      </fieldset>
+      <fieldset>
+        <section>
+          <span class="title">New GPG key</span>
+          <span class="value">
+            <iron-autogrow-textarea
+                id="newKey"
+                autocomplete="on"
+                bind-value="{{_newKey}}"
+                placeholder="New GPG Key"></iron-autogrow-textarea>
+          </span>
+        </section>
+        <gr-button
+            id="addButton"
+            disabled$="[[_computeAddButtonDisabled(_newKey)]]"
+            on-tap="_handleAddKey">Add new GPG key</gr-button>
+      </fieldset>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-gpg-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
new file mode 100644
index 0000000..f5bf8bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
@@ -0,0 +1,102 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-gpg-editor',
+
+    properties: {
+      hasUnsavedChanges: {
+        type: Boolean,
+        value: false,
+        notify: true,
+      },
+      _keys: Array,
+      /** @type {?} */
+      _keyToView: Object,
+      _newKey: {
+        type: String,
+        value: '',
+      },
+      _keysToRemove: {
+        type: Array,
+        value() { return []; },
+      },
+    },
+
+    loadData() {
+      this._keys = [];
+      return this.$.restAPI.getAccountGPGKeys().then(keys => {
+        if (!keys) {
+          return;
+        }
+        this._keys = Object.keys(keys)
+         .map(key => {
+           const gpgKey = keys[key];
+           gpgKey.id = key;
+           return gpgKey;
+         });
+      });
+    },
+
+    save() {
+      const promises = this._keysToRemove.map(key => {
+        this.$.restAPI.deleteAccountGPGKey(key.id);
+      });
+
+      return Promise.all(promises).then(() => {
+        this._keysToRemove = [];
+        this.hasUnsavedChanges = false;
+      });
+    },
+
+    _showKey(e) {
+      const el = Polymer.dom(e).localTarget;
+      const index = parseInt(el.getAttribute('data-index'), 10);
+      this._keyToView = this._keys[index];
+      this.$.viewKeyOverlay.open();
+    },
+
+    _closeOverlay() {
+      this.$.viewKeyOverlay.close();
+    },
+
+    _handleDeleteKey(e) {
+      const el = Polymer.dom(e).localTarget;
+      const index = parseInt(el.getAttribute('data-index'), 10);
+      this.push('_keysToRemove', this._keys[index]);
+      this.splice('_keys', index, 1);
+      this.hasUnsavedChanges = true;
+    },
+
+    _handleAddKey() {
+      this.$.addButton.disabled = true;
+      this.$.newKey.disabled = true;
+      return this.$.restAPI.addAccountGPGKey({add: [this._newKey.trim()]})
+          .then(key => {
+            this.$.newKey.disabled = false;
+            this._newKey = '';
+            this.loadData();
+          }).catch(() => {
+            this.$.addButton.disabled = false;
+            this.$.newKey.disabled = false;
+          });
+    },
+
+    _computeAddButtonDisabled(newKey) {
+      return !newKey.length;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
new file mode 100644
index 0000000..f749130
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
@@ -0,0 +1,192 @@
+<!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-gpg-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-gpg-editor.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-gpg-editor></gr-gpg-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-gpg-editor tests', () => {
+    let element;
+    let keys;
+
+    setup(done => {
+      const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
+      const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
+      keys = {
+        AFC8A49B: {
+          fingerprint: fingerprint1,
+          user_ids: [
+            'John Doe john.doe@example.com',
+          ],
+          key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+               '\nVersion: BCPG v1.52\n\t<key 1>',
+          status: 'TRUSTED',
+          problems: [],
+        },
+        AED9B59C: {
+          fingerprint: fingerprint2,
+          user_ids: [
+            'Gerrit gerrit@example.com',
+          ],
+          key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+               '\nVersion: BCPG v1.52\n\t<key 2>',
+          status: 'TRUSTED',
+          problems: [],
+        },
+      };
+
+      stub('gr-rest-api-interface', {
+        getAccountGPGKeys() { return Promise.resolve(keys); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(() => { flush(done); });
+    });
+
+    test('renders', () => {
+      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 2);
+
+      let cells = rows[0].querySelectorAll('td');
+      assert.equal(cells[0].textContent, 'AFC8A49B');
+
+      cells = rows[1].querySelectorAll('td');
+      assert.equal(cells[0].textContent, 'AED9B59C');
+    });
+
+    test('remove key', done => {
+      const lastKey = keys[Object.keys(keys)[1]];
+
+      const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey',
+          () => { return Promise.resolve(); });
+
+      assert.equal(element._keysToRemove.length, 0);
+      assert.isFalse(element.hasUnsavedChanges);
+
+      // Get the delete button for the last row.
+      const button = Polymer.dom(element.root).querySelector(
+          'tbody tr:last-of-type td:nth-child(5) gr-button');
+
+      MockInteractions.tap(button);
+
+      assert.equal(element._keys.length, 1);
+      assert.equal(element._keysToRemove.length, 1);
+      assert.equal(element._keysToRemove[0], lastKey);
+      assert.isTrue(element.hasUnsavedChanges);
+      assert.isFalse(saveStub.called);
+
+      element.save().then(() => {
+        assert.isTrue(saveStub.called);
+        assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
+        assert.equal(element._keysToRemove.length, 0);
+        assert.isFalse(element.hasUnsavedChanges);
+        done();
+      });
+    });
+
+    test('show key', () => {
+      const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+
+      // Get the show button for the last row.
+      const button = Polymer.dom(element.root).querySelector(
+          'tbody tr:last-of-type td:nth-child(4) gr-button');
+
+      MockInteractions.tap(button);
+
+      assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
+      assert.isTrue(openSpy.called);
+    });
+
+    test('add key', done => {
+      const newKeyString =
+          '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+          '\nVersion: BCPG v1.52\n\t<key 3>';
+      const newKeyObject = {
+        ADE8A59B: {
+          fingerprint: '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B',
+          user_ids: [
+            'John john@example.com',
+          ],
+          key: newKeyString,
+          status: 'TRUSTED',
+          problems: [],
+        },
+      };
+
+      const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
+          () => { return Promise.resolve(newKeyObject); });
+
+      element._newKey = newKeyString;
+
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+
+      element._handleAddKey().then(() => {
+        assert.isTrue(element.$.addButton.disabled);
+        assert.isFalse(element.$.newKey.disabled);
+        assert.equal(element._keys.length, 2);
+        done();
+      });
+
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isTrue(element.$.newKey.disabled);
+
+      assert.isTrue(addStub.called);
+      assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+    });
+
+    test('add invalid key', done => {
+      const newKeyString = 'not even close to valid';
+
+      const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
+          () => { return Promise.reject(); });
+
+      element._newKey = newKeyString;
+
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+
+      element._handleAddKey().then(() => {
+        assert.isFalse(element.$.addButton.disabled);
+        assert.isFalse(element.$.newKey.disabled);
+        assert.equal(element._keys.length, 2);
+        done();
+      });
+
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isTrue(element.$.newKey.disabled);
+
+      assert.isTrue(addStub.called);
+      assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+    });
+  });
+</script>
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 c70ae88..5964578 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
@@ -32,7 +32,7 @@
 </test-fixture>
 
 <script>
-  suite('gr-settings-view tests', () => {
+  suite('gr-menu-editor tests', () => {
     let element;
     let menu;
 
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 a1de1b2..416c426 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
@@ -31,6 +31,7 @@
 <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-gpg-editor/gr-gpg-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">
@@ -73,6 +74,9 @@
           <li hidden$="[[!_serverConfig.sshd]]"><a href="#SSHKeys">
             SSH Keys
           </a></li>
+          <li hidden$="[[!_serverConfig.receive.enable_signed_push]]"><a href="#GPGKeys">
+            GPG 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]]">
@@ -414,6 +418,14 @@
               id="sshEditor"
               has-unsaved-changes="{{_keysChanged}}"></gr-ssh-editor>
         </div>
+        <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
+          <h2
+              id="GPGKeys"
+              class$="[[_computeHeaderClass(_gpgKeysChanged)]]">GPG keys</h2>
+          <gr-gpg-editor
+              id="gpgEditor"
+              has-unsaved-changes="{{_gpgKeysChanged}}"></gr-gpg-editor>
+        </div>
         <h2 id="Groups">Groups</h2>
         <fieldset>
           <gr-group-list id="groupList"></gr-group-list>
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 8e14018..912712c 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
@@ -104,6 +104,10 @@
         type: Boolean,
         value: false,
       },
+      _gpgKeysChanged: {
+        type: Boolean,
+        value: false,
+      },
       _newEmail: String,
       _addingEmail: {
         type: Boolean,
@@ -167,10 +171,16 @@
         this._serverConfig = config;
         const configPromises = [];
 
-        if (this._serverConfig.sshd) {
+        if (this._serverConfig && this._serverConfig.sshd) {
           configPromises.push(this.$.sshEditor.loadData());
         }
 
+        if (this._serverConfig &&
+            this._serverConfig.receive &&
+            this._serverConfig.receive.enable_signed_push) {
+          configPromises.push(this.$.gpgEditor.loadData());
+        }
+
         configPromises.push(
             this.getDocsBaseUrl(config, this.$.restAPI)
                 .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
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 52fc69b..de92b97 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
@@ -17,6 +17,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-account-link/gr-account-link.html">
 <link rel="import" href="../gr-button/gr-button.html">
+<link rel="import" href="../gr-icons/gr-icons.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -52,7 +53,6 @@
           height: .6em;
           line-height: .6;
           margin-left: .15em;
-          margin-top: -.05em;
           padding: 0;
           text-decoration: none;
         }
@@ -74,6 +74,10 @@
         opacity: .6;
         pointer-events: none;
       }
+      iron-icon {
+        height: 1.2rem;
+        width: 1.2rem;
+      }
     </style>
     <div class$="container [[_getBackgroundClass(transparentBackground)]]">
       <gr-account-link account="[[account]]"
@@ -87,7 +91,9 @@
           tabindex="-1"
           aria-label="Remove"
           class$="remove [[_getBackgroundClass(transparentBackground)]]"
-          on-tap="_handleRemoveTap">ร—</gr-button>
+          on-tap="_handleRemoveTap">
+        <iron-icon icon="gr-icons:close"></iron-icon>
+      </gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
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 6801dea..968143a 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
@@ -54,12 +54,12 @@
         justify-content: flex-end;
       }
     </style>
-    <div class="container">
+    <div class="container" on-keydown="_handleKeydown">
       <header><slot name="header"></slot></header>
       <main><slot name="main"></slot></main>
       <footer>
         <gr-button link on-tap="_handleCancelTap">[[cancelLabel]]</gr-button>
-        <gr-button link primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
+        <gr-button link primary on-tap="_handleConfirm" disabled="[[disabled]]">
           [[confirmLabel]]
         </gr-button>
       </footer>
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 f322e25..8916296 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
@@ -42,13 +42,19 @@
         type: Boolean,
         value: false,
       },
+      confirmOnEnter: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     hostAttributes: {
       role: 'dialog',
     },
 
-    _handleConfirmTap(e) {
+    _handleConfirm(e) {
+      if (this.disabled) { return; }
+
       e.preventDefault();
       this.fire('confirm', null, {bubbles: false});
     },
@@ -57,5 +63,9 @@
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
+
+    _handleKeydown(e) {
+      if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
index 309e15c..a2ae160 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
@@ -34,11 +34,15 @@
 <script>
   suite('gr-confirm-dialog tests', () => {
     let element;
+    let sandbox;
 
     setup(() => {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
+    teardown(() => { sandbox.restore(); });
+
     test('events', done => {
       let numEvents = 0;
       function handler() { if (++numEvents == 2) { done(); } }
@@ -49,5 +53,24 @@
       MockInteractions.tap(element.$$('gr-button[primary]'));
       MockInteractions.tap(element.$$('gr-button:not([primary])'));
     });
+
+    test('confirmOnEnter', () => {
+      element.confirmOnEnter = false;
+      const handleConfirmStub = sandbox.stub(element, '_handleConfirm');
+      const handleKeydownSpy = sandbox.spy(element, '_handleKeydown');
+      MockInteractions.pressAndReleaseKeyOn(element.$$('main'),
+          13, null, 'enter');
+      flushAsynchronousOperations();
+
+      assert.isTrue(handleKeydownSpy.called);
+      assert.isFalse(handleConfirmStub.called);
+
+      element.confirmOnEnter = true;
+      MockInteractions.pressAndReleaseKeyOn(element.$$('main'),
+          13, null, 'enter');
+      flushAsynchronousOperations();
+
+      assert.isTrue(handleConfirmStub.called);
+    });
   });
 </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 34a5af5..06aa43b 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -33,6 +33,12 @@
       <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
       <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"/></g>
       <!-- This is a custom PolyGerrit SVG -->
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 e56498b..ca0b90a 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
@@ -17,6 +17,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../gr-button/gr-button.html">
+<link rel="import" href="../gr-icons/gr-icons.html">
 <link rel="import" href="../gr-limited-text/gr-limited-text.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 
@@ -49,7 +50,6 @@
           height: .6em;
           line-height: .6;
           margin-left: .15em;
-          margin-top: -.05em;
           padding: 0;
           text-decoration: none;
         }
@@ -65,6 +65,10 @@
       a {
        color: var(--linked-chip-text-color);
       }
+      iron-icon {
+        height: 1.2rem;
+        width: 1.2rem;
+      }
     </style>
     <div class$="container [[_getBackgroundClass(transparentBackground)]]">
       <a href$="[[href]]">
@@ -78,7 +82,9 @@
           hidden$="[[!removable]]"
           hidden
           class$="remove [[_getBackgroundClass(transparentBackground)]]"
-          on-tap="_handleRemoveTap">ร—</gr-button>
+          on-tap="_handleRemoveTap">
+        <iron-icon icon="gr-icons:close"></iron-icon>
+      </gr-button>
     </div>
   </template>
   <script src="gr-linked-chip.js"></script>
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 cbb2287..27f330e 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
@@ -437,12 +437,16 @@
           .then(response => this.getResponseObject(response));
     },
 
-    saveIncludedGroup(groupName, includedGroup) {
+    saveIncludedGroup(groupName, includedGroup, opt_errFn) {
       const encodeName = encodeURIComponent(groupName);
       const encodeIncludedGroup = encodeURIComponent(includedGroup);
       return this.send('PUT',
-          `/groups/${encodeName}/groups/${encodeIncludedGroup}`)
-          .then(response => this.getResponseObject(response));
+          `/groups/${encodeName}/groups/${encodeIncludedGroup}`, null,
+          opt_errFn).then(response => {
+            if (response.ok) {
+              return this.getResponseObject(response);
+            }
+          });
     },
 
     deleteGroupMembers(groupName, groupMembers) {
@@ -1418,9 +1422,15 @@
      * @param {number|string} patchNum
      */
     getFileContent(changeNum, path, patchNum) {
+      // 404s indicate the file does not exist yet in the revision, so suppress
+      // them.
+      const suppress404s = res => {
+        if (res && res.status !== 404) { this.fire('server-error', {res}); }
+        return res;
+      };
       const promise = this.patchNumEquals(patchNum, this.EDIT_NAME) ?
           this._getFileInChangeEdit(changeNum, path) :
-          this._getFileInRevision(changeNum, path, patchNum);
+          this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
       return promise.then(res => {
         if (!res.ok) { return res; }
@@ -1439,12 +1449,13 @@
      * @param {number|string} changeNum
      * @param {string} path
      * @param {number|string} patchNum
+     * @param {?function(?Response, string=)=} opt_errFn
      */
-    _getFileInRevision(changeNum, path, patchNum) {
+    _getFileInRevision(changeNum, path, patchNum, opt_errFn) {
       const e = `/files/${encodeURIComponent(path)}/content`;
       const headers = {Accept: 'application/json'};
-      return this.getChangeURLAndSend(changeNum, 'GET', patchNum, e, null, null,
-          null, null, headers);
+      return this.getChangeURLAndSend(changeNum, 'GET', patchNum, e, null,
+          opt_errFn, null, null, headers);
     },
 
     /**
@@ -1941,6 +1952,28 @@
       return this.send('DELETE', '/accounts/self/sshkeys/' + id);
     },
 
+    getAccountGPGKeys() {
+      return this.fetchJSON('/accounts/self/gpgkeys');
+    },
+
+    addAccountGPGKey(key) {
+      return this.send('POST', '/accounts/self/gpgkeys', key)
+          .then(response => {
+            if (response.status < 200 && response.status >= 300) {
+              return Promise.reject();
+            }
+            return this.getResponseObject(response);
+          })
+          .then(obj => {
+            if (!obj) { return Promise.reject(); }
+            return obj;
+          });
+    },
+
+    deleteAccountGPGKey(id) {
+      return this.send('DELETE', '/accounts/self/gpgkeys/' + id);
+    },
+
     deleteVote(changeNum, account, label) {
       const e = `/reviewers/${account}/votes/${encodeURIComponent(label)}`;
       return this.getChangeURLAndSend(changeNum, 'DELETE', null, e);
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 efe70a9..352f759 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
@@ -1277,6 +1277,23 @@
       return Promise.all([edit, normal]);
     });
 
+    test('getFileContent suppresses 404s', done => {
+      const res = {status: 404};
+      const handler = e => {
+        assert.isFalse(e.detail.res.status === 404);
+        done();
+      };
+      element.addEventListener('server-error', handler);
+      sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve(res));
+      sandbox.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
+      element.getFileContent('1', 'tst/path', '1').then(() => {
+        flushAsynchronousOperations();
+
+        res.status = 500;
+        element.getFileContent('1', 'tst/path', '1');
+      });
+    });
+
     test('getChangeFilesAsSpeciallySortedArray is edit-sensitive', () => {
       const fn = element.getChangeFilesAsSpeciallySortedArray.bind(element);
       const getChangeFilesStub = sandbox.stub(element, 'getChangeFiles')
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 145363b..0d03910 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -16,7 +16,7 @@
     # See: https://github.com/google/closure-compiler/issues/2042
     compilation_level = "WHITESPACE_ONLY",
     defs = [
-      "--polymer_pass",
+      "--polymer_version=1",
       "--jscomp_off=duplicate",
       "--force_inject_library=es6_runtime",
     ],
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index 566d098..c109381 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -19,13 +19,12 @@
       :host {
         font-size: var(--font-size-normal);
       }
-      gr-change-list-item:not([selected]),
+      gr-change-list-item,
       tr {
-        border-top: 1px solid #ddd;
+        border-bottom: 1px solid #ddd;
       }
-      tbody {
-        border: 1px solid #ddd;
-        border-top: none;
+      tr.topHeader {
+        border: none;
       }
       th {
         text-align: left;
@@ -38,17 +37,38 @@
       .cell:not(.label) {
         padding-right: 8px;
       }
+      th.label {
+        border-left: none;
+      }
       .topHeader,
       .groupHeader {
         font-family: var(--font-family-bold);
       }
-      .topHeader {
+      .topHeader th {
         background-color: #fafafa;
         font-size: var(--font-size-large);
         height: 3rem;
+        position: -webkit-sticky;
+        position: sticky;
+        top: -1px; /* Offset for top borders */
+        z-index: 1;
+      }
+      /* :after pseudoelements are used here because borders on sticky table
+        headers with a background color are broken. */
+      th:after {
+        border-bottom: 1px solid #ddd;
+        bottom: 0;
+        content: '';
+        left: 0;
+        position: absolute;
+        width: 100%;
+      }
+      th.label:after {
+        border-left: 1px solid #ddd;
+        top: 0;
       }
       .groupHeader {
-        background-color: #f4f4f4;
+        background-color: #eaeaea;
       }
       .groupHeader a {
         color: #000;
@@ -91,11 +111,30 @@
         width: 30px;
       }
       .label {
+        border-left: 1px solid #ddd;
         text-align: center;
         width: 3rem;
       }
-      .label:not(.topHeader) {
-        border-left: 1px solid #ddd;
+      .topHeader .label {
+        border: none;
+      }
+      .truncatedProject {
+        display: none;
+      }
+      @media only screen and (max-width: 90em) {
+        .assignee,
+        .branch,
+        .owner {
+          overflow: hidden;
+          max-width: 10rem;
+          text-overflow: ellipsis;
+        }
+        .truncatedProject {
+          display: inline-block;
+        }
+        .fullProject {
+          display: none;
+        }
       }
       @media only screen and (max-width: 50em) {
         :host {
diff --git a/polygerrit-ui/app/template_test.sh b/polygerrit-ui/app/template_test.sh
index 5ffc434..a9710cd 100755
--- a/polygerrit-ui/app/template_test.sh
+++ b/polygerrit-ui/app/template_test.sh
@@ -22,6 +22,12 @@
     exit 1
 fi
 
+twinkie_version=$(npm list -g fried-twinkie@\>0.1 | grep fried-twinkie || :)
+if [ -z "$twinkie_version" ]; then
+    echo "Outdated version of fried-twinkie found. Bypassing template check."
+    exit 0
+fi
+
 # Have to find where node_modules are installed and set the NODE_PATH
 
 get_node_path() {
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
index 7c64db3..3de6227 100644
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -45,14 +45,18 @@
     console.log('error /polygerrit-ui/temp/behaviors/ directory');
   }
   const behaviors = data;
-  const externs = [];
+  const additionalSources = [];
+  const externMap = {};
 
   for (const behavior of behaviors) {
-    externs.push({
-      path: `./polygerrit-ui/temp/behaviors/${behavior}`,
-      src: fs.readFileSync(
-          `./polygerrit-ui/temp/behaviors/${behavior}`, 'utf-8'),
-    });
+    if (!externMap[behavior]) {
+      additionalSources.push({
+        path: `./polygerrit-ui/temp/behaviors/${behavior}`,
+        src: fs.readFileSync(
+            `./polygerrit-ui/temp/behaviors/${behavior}`, 'utf-8'),
+      });
+      externMap[behavior] = true;
+    }
   }
 
   let mappings = JSON.parse(fs.readFileSync(
@@ -83,28 +87,30 @@
     mappings = mappingSpecificFile;
   }
 
-  externs.push({
+  additionalSources.push({
     path: 'custom-externs.js',
     src: '/** @externs */' +
         EXTERN_NAMES.map( name => { return `var ${name};`; }).join(' '),
   });
 
-  const promises = [];
-
+  const toCheck = [];
   for (key of Object.keys(mappings)) {
     if (mappings[key].html && mappings[key].js) {
-      promises.push(twinkie.checkTemplate(
-          mappings[key].html,
-          mappings[key].js,
-          'polygerrit.' + mappings[key].package,
-          externs
-      ));
+      toCheck.push({
+        htmlSrcPath: mappings[key].html,
+        jsSrcPath: mappings[key].js,
+        jsModule: 'polygerrit.' + mappings[key].package,
+      });
     }
   }
 
-  Promise.all(promises).then(() => {}, joinedErrors => {
-    if (joinedErrors) {
-      process.exit(1);
-    }
-  });
+  twinkie.checkTemplate(toCheck, additionalSources)
+      .then(() => {}, joinedErrors => {
+        if (joinedErrors) {
+          process.exit(1);
+        }
+      }).catch(e => {
+        console.error(e);
+        process.exit(1);
+      });
 });
diff --git a/polygerrit-ui/app/test/functional/README.md b/polygerrit-ui/app/test/functional/README.md
new file mode 100644
index 0000000..82c6133
--- /dev/null
+++ b/polygerrit-ui/app/test/functional/README.md
@@ -0,0 +1,54 @@
+# Functional test suite
+
+## Installing Docker (OSX)
+
+Simplest way to install all of those is to use Homebrew:
+
+```
+brew cask install docker
+```
+
+This will install a Docker in Applications. To run if from the command-line:
+
+```
+open /Applications/Docker.app
+```
+
+It'll require privileged access and will require user password to be entered.
+
+To validate Docker is installed correctly, run hello-world image:
+
+```
+docker run hello-world
+```
+
+## Building a Docker image
+
+Should be done once only for development purposes, run from the Gerrit checkout
+path:
+
+```
+docker build -t gerrit/polygerrit-functional:v1 \
+  polygerrit-ui/app/test/functional/infra
+```
+
+## Running a smoke test
+
+Running a smoke test from Gerrit checkout path:
+
+```
+./polygerrit-ui/app/test/functional/run_functional.sh
+```
+
+The successful output should be something similar to this:
+
+```
+Starting local server..
+Starting Webdriver..
+Started
+.
+
+
+1 spec, 0 failures
+Finished in 2.565 seconds
+```
diff --git a/polygerrit-ui/app/test/functional/infra/Dockerfile b/polygerrit-ui/app/test/functional/infra/Dockerfile
new file mode 100644
index 0000000..e642176
--- /dev/null
+++ b/polygerrit-ui/app/test/functional/infra/Dockerfile
@@ -0,0 +1,38 @@
+FROM selenium/standalone-chrome-debug
+
+USER root
+
+# nvm environment variables
+ENV NVM_DIR /usr/local/nvm
+ENV NODE_VERSION 9.4.0
+
+# install nvm
+# https://github.com/creationix/nvm#install-script
+RUN wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash
+
+# install node and npm
+RUN [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" \
+    && nvm install $NODE_VERSION \
+    && nvm alias default $NODE_VERSION \
+    && nvm use default
+
+ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules
+ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
+
+RUN npm install -g jasmine
+RUN npm install -g http-server
+
+USER seluser
+
+RUN mkdir -p /tmp/app
+WORKDIR /tmp/app
+
+RUN npm init -y
+RUN npm install --save selenium-webdriver
+
+EXPOSE 8080
+
+COPY test-infra.js /tmp/app/node_modules
+COPY run.sh /tmp/app/
+
+ENTRYPOINT [ "/tmp/app/run.sh" ]
diff --git a/polygerrit-ui/app/test/functional/infra/run.sh b/polygerrit-ui/app/test/functional/infra/run.sh
new file mode 100755
index 0000000..4beb3dd
--- /dev/null
+++ b/polygerrit-ui/app/test/functional/infra/run.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+echo Starting local server..
+cp /app/polygerrit_ui.zip .
+unzip -q polygerrit_ui.zip
+nohup http-server polygerrit_ui > /tmp/http-server.log 2>&1 &
+
+echo Starting Webdriver..
+nohup /opt/bin/entry_point.sh > /tmp/webdriver.log 2>&1 &
+
+# Wait for servers to start
+sleep 5
+
+cp $@ .
+jasmine $(basename $@)
diff --git a/polygerrit-ui/app/test/functional/infra/test-infra.js b/polygerrit-ui/app/test/functional/infra/test-infra.js
new file mode 100644
index 0000000..2619694
--- /dev/null
+++ b/polygerrit-ui/app/test/functional/infra/test-infra.js
@@ -0,0 +1,24 @@
+'use strict';
+
+const {Builder} = require('selenium-webdriver');
+
+let driver;
+
+function setup() {
+  return new Builder()
+      .forBrowser('chrome')
+      .usingServer('http://localhost:4444/wd/hub')
+      .build()
+      .then(d => {
+        driver = d;
+        return driver.get('http://localhost:8080');
+      })
+      .then(() => driver);
+}
+
+function cleanup() {
+  return driver.quit();
+}
+
+exports.setup = setup;
+exports.cleanup = cleanup;
diff --git a/polygerrit-ui/app/test/functional/run_functional.sh b/polygerrit-ui/app/test/functional/run_functional.sh
new file mode 100755
index 0000000..7ce57b8
--- /dev/null
+++ b/polygerrit-ui/app/test/functional/run_functional.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+bazel build //polygerrit-ui/app:polygerrit_ui
+
+docker run --rm \
+  -p 5900:5900 \
+  -v `pwd`/polygerrit-ui/app/test/functional:/tests \
+  -v `pwd`/bazel-genfiles/polygerrit-ui/app:/app \
+  -it gerrit/polygerrit-functional:v1 \
+  /tests/test.js
diff --git a/polygerrit-ui/app/test/functional/test.js b/polygerrit-ui/app/test/functional/test.js
new file mode 100644
index 0000000..d394487
--- /dev/null
+++ b/polygerrit-ui/app/test/functional/test.js
@@ -0,0 +1,25 @@
+/**
+ * @fileoverview Minimal viable frontend functional test.
+ */
+'use strict';
+
+const {until} = require('selenium-webdriver');
+const {setup, cleanup} = require('test-infra');
+
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
+
+describe('example ', () => {
+  let driver;
+
+  beforeAll(() => {
+    return setup().then(d => driver = d);
+  });
+
+  afterAll(() => {
+    return cleanup();
+  });
+
+  it('should update title', () => {
+    return driver.wait(until.titleIs('status:open ยท Gerrit Code Review'), 5000);
+  });
+});
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index ced836c..2bb8b2e 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -55,6 +55,7 @@
     'change-list/gr-change-list-item/gr-change-list-item_test.html',
     'change-list/gr-change-list-view/gr-change-list-view_test.html',
     'change-list/gr-change-list/gr-change-list_test.html',
+    'change-list/gr-dashboard-view/gr-dashboard-view_test.html',
     'change-list/gr-user-header/gr-user-header_test.html',
     'change/gr-account-entry/gr-account-entry_test.html',
     'change/gr-account-list/gr-account-list_test.html',
@@ -64,6 +65,7 @@
     'change/gr-change-view/gr-change-view_test.html',
     'change/gr-comment-list/gr-comment-list_test.html',
     'change/gr-commit-info/gr-commit-info_test.html',
+    'change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html',
     'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
     'change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html',
     'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
@@ -79,6 +81,7 @@
     'change/gr-reply-dialog/gr-reply-dialog-it_test.html',
     'change/gr-reply-dialog/gr-reply-dialog_test.html',
     'change/gr-reviewer-list/gr-reviewer-list_test.html',
+    'change/gr-thread-list/gr-thread-list_test.html',
     'core/gr-account-dropdown/gr-account-dropdown_test.html',
     'core/gr-error-manager/gr-error-manager_test.html',
     'core/gr-main-header/gr-main-header_test.html',
@@ -94,6 +97,7 @@
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
     'diff/gr-diff-highlight/gr-diff-highlight_test.html',
+    'diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html',
     'diff/gr-diff-preferences/gr-diff-preferences_test.html',
     'diff/gr-diff-processor/gr-diff-processor_test.html',
     'diff/gr-diff-selection/gr-diff-selection_test.html',
@@ -123,6 +127,7 @@
     '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-gpg-editor/gr-gpg-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',
diff --git a/polygerrit-ui/edit-walkthrough/edit-walkthrough.md b/polygerrit-ui/edit-walkthrough/edit-walkthrough.md
new file mode 100644
index 0000000..717f683
--- /dev/null
+++ b/polygerrit-ui/edit-walkthrough/edit-walkthrough.md
@@ -0,0 +1,80 @@
+# In-browser Editing in Gerrit
+
+### What's going on?
+
+Until Q1 of 2018, editing a file in the browser was not supported by Gerrit's
+new UI. This feature is now done and ready for use.
+
+Read on for a walkthrough of the feature!
+
+### Creating an edit
+
+Click on the "Edit" button to begin.
+
+One may also go to the project mmanagement page (Browse => Repository =>
+Commands => Create Change) to create a new change.
+
+![](./img/into_edit.png)
+
+### Performing an action
+
+The buttons in the file list header open dialogs to perform actions on any file
+in the repo.
+
+*   Open - opens an existing or new file from the repo in an editor.
+*   Delete - deletes an existing file from the repo.
+*   Rename - renames an existing file in the repo.
+
+To leave edit mode and restore the normal buttons to the file list, click "Stop
+editing".
+
+![](./img/in_edit_mode.png)
+
+### Performing an action on a file
+
+The "Actions" dropdown appears on each file, and is used to perform actions on
+that specific file.
+
+*   Open - opens this file in the editor.
+*   Delete - deletes this file from the repo.
+*   Rename - renames this file in the repo.
+*   Restore - restores this file to the state it existed in at the patch the
+edit was created on.
+
+![](./img/actions_overflow.png)
+
+### Modifying the file
+
+This is the editor view.
+
+Clicking on the file path allows you to rename the file, You can edit code in
+the textarea, and "Close" will discard any unsaved changes and navigate back to
+the previous view.
+
+![](./img/in_editor.png)
+
+### Saving the edit
+
+You can save changes to the code with `cmd+s`, `ctrl+s`, or by clicking the
+"Save" button.
+
+![](./img/edit_made.png)
+
+### Publishing the edit
+
+You may publish or delete the edit by clicking the buttons in the header.
+
+
+
+![](./img/edit_pending.png)
+
+### What if I have questions not answered here?
+
+Gerrit's [official docs](https://gerrit-review.googlesource.com/Documentation/user-inline-edit.html)
+are in the process of being updated and largely refer to the old UI, but the
+user experience is largely the same.
+
+Otherwise, please email
+[the repo-discuss mailing list](mailto:repo-discuss@google.com) or file a bug
+on Gerrit's official bug tracker,
+[Monorail](https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit+Issue).
\ No newline at end of file
diff --git a/polygerrit-ui/edit-walkthrough/img/actions_overflow.png b/polygerrit-ui/edit-walkthrough/img/actions_overflow.png
new file mode 100644
index 0000000..bf39763
--- /dev/null
+++ b/polygerrit-ui/edit-walkthrough/img/actions_overflow.png
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/edit_made.png b/polygerrit-ui/edit-walkthrough/img/edit_made.png
new file mode 100644
index 0000000..658245d
--- /dev/null
+++ b/polygerrit-ui/edit-walkthrough/img/edit_made.png
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/edit_pending.png b/polygerrit-ui/edit-walkthrough/img/edit_pending.png
new file mode 100644
index 0000000..a63f6ee
--- /dev/null
+++ b/polygerrit-ui/edit-walkthrough/img/edit_pending.png
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png b/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png
new file mode 100644
index 0000000..582ed66
--- /dev/null
+++ b/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/in_editor.png b/polygerrit-ui/edit-walkthrough/img/in_editor.png
new file mode 100644
index 0000000..228d020
--- /dev/null
+++ b/polygerrit-ui/edit-walkthrough/img/in_editor.png
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/into_edit.png b/polygerrit-ui/edit-walkthrough/img/into_edit.png
new file mode 100644
index 0000000..b6c14ed
--- /dev/null
+++ b/polygerrit-ui/edit-walkthrough/img/into_edit.png
Binary files differ
diff --git a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt b/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
index 757e8e2..a380955 100644
--- a/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
+++ b/resources/com/google/gerrit/pgm/ProtoGenHeader.txt
@@ -11,8 +11,6 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-//
-// Gerrit Code Review (version @@VERSION@@)
 
 syntax = "proto2";
 
diff --git a/resources/com/google/gerrit/server/mail/AddKey.soy b/resources/com/google/gerrit/server/mail/AddKey.soy
index af99569..be76aee 100644
--- a/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -64,8 +64,5 @@
   browser window instead.
 
   {\n}
-  {\n}
-
-  This is a send-only email address.  Replies to this message will not be read
-  or answered.
+  {call .NoReplyFooter /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index 712abc7..04a0635 100644
--- a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -59,8 +59,5 @@
     {/if}.
   </p>
 
-  <p>
-    This is a send-only email address.  Replies to this message will not be read
-    or answered.
-  </p>
+  {call .NoReplyFooterHtml /}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
new file mode 100644
index 0000000..e997776
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
@@ -0,0 +1,64 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .InboundEmailRejectionFooter kind="text"}
+  {\n}
+  {\n}
+  Thus, no actions were taken by Gerrit in response to this email,
+  and you should use the Gerrit website to continue.
+  {\n}
+  This email was sent in response to an email coming from this address.
+  In case you did not send Gerrit an email, feel free to ignore this.
+  {call .NoReplyFooter /}
+{/template}
+
+/**
+ * The .InboundEmailRejection templates will determine the contents of the email related
+ * to warning users of error in inbound emails
+ */
+
+{template .InboundEmailRejection_PARSING_ERROR kind="text"}
+  Gerrit Code Review was unable to parse your email.{\n}
+  This might be because your email did not quote Gerrit's email,
+  because you are using an unsupported email client,
+  or because of a bug.
+  {call .InboundEmailRejectionFooter /}
+{/template}
+
+{template .InboundEmailRejection_UNKNOWN_ACCOUNT kind="text"}
+  Gerrit Code Review was unable to match your email to an account.{\n}
+  This may happen if several accounts are linked to this email address.
+  {call .InboundEmailRejectionFooter /}
+{/template}
+
+{template .InboundEmailRejection_INACTIVE_ACCOUNT kind="text"}
+  Your account on this Gerrit Code Review instance is marked as inactive,
+  so your email has been ignored. {\n}
+  If you think this is an error, please contact your Gerrit instance administrator.
+  {\n}{\n}
+  This email was sent in response to an email coming from this address.
+  In case you did not send Gerrit an email, feel free to ignore this.
+  {call .NoReplyFooter /}
+{/template}
+
+{template .InboundEmailRejection_INTERNAL_EXCEPTION kind="text"}
+  Gerrit Code Review encountered an internal exception and was unable to fulfil your request.
+  {\n}
+  This might be caused by an ongoing maintenance or a data corruption.
+  {call .InboundEmailRejectionFooter /}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
new file mode 100644
index 0000000..f879270
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
@@ -0,0 +1,80 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+
+{template .InboundEmailRejectionFooterHtml}
+  <p>
+    Thus, no actions were taken by Gerrit in response to this email,
+    and you should use the Gerrit website to continue.
+  </p>
+  <p>
+    In case you did not send Gerrit an email, feel free to ignore this.
+  </p>
+  {call .NoReplyFooterHtml /}
+{/template}
+
+/**
+ * The .InboundEmailRejection templates will determine the contents of the email related
+ * to warning users of error in inbound emails
+ */
+
+{template .InboundEmailRejectionHtml_PARSING_ERROR}
+  <p>
+    Gerrit Code Review was unable to parse your email.
+  </p>
+  <p>
+    This might be because your email did not quote Gerrit's email,
+    because you are using an unsupported email client,
+    or because of a bug.
+  </p>
+  {call .InboundEmailRejectionFooterHtml /}
+{/template}
+
+{template .InboundEmailRejectionHtml_UNKNOWN_ACCOUNT}
+  <p>
+    Gerrit Code Review was unable to match your email to an account.
+  </p>
+  <p>
+    This may happen if several accounts are linked to this email address.
+  </p>
+  {call .InboundEmailRejectionFooterHtml /}
+{/template}
+
+{template .InboundEmailRejectionHtml_INACTIVE_ACCOUNT}
+  <p>
+    Your account on this Gerrit Code Review instance is marked as inactive,
+    so your email has been ignored.
+  </p>
+  <p>
+    If you think this is an error, please contact your Gerrit instance administrator.
+  </p>
+  <p>
+    In case you did not send Gerrit an email, feel free to ignore this.
+  </p>
+  {call .NoReplyFooter /}
+{/template}
+
+{template .InboundEmailRejectionHtml_INTERNAL_EXCEPTION}
+  <p>
+    Gerrit Code Review encountered an internal exception and was unable to fulfil your request.
+  </p>
+  <p>
+    This might be caused by an ongoing maintenance or a data corruption.
+  <p>
+  {call .InboundEmailRejectionFooterHtml /}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Merged.soy b/resources/com/google/gerrit/server/mail/Merged.soy
index 1d6ae9c..40924e6 100644
--- a/resources/com/google/gerrit/server/mail/Merged.soy
+++ b/resources/com/google/gerrit/server/mail/Merged.soy
@@ -1,3 +1,4 @@
+
 /**
  * Copyright (C) 2016 The Android Open Source Project
  *
@@ -21,16 +22,10 @@
  * a change successfully merged to the head.
  * @param change
  * @param email
- * @param fromEmail
  * @param fromName
- * @param patchSetInfo
  */
 {template .Merged kind="text"}
-  {$fromName} merged this change
-  {if $patchSetInfo.authorEmail != $fromEmail}
-    {sp}by {$patchSetInfo.authorName}
-  {/if}.
-
+  {$fromName} has submitted this change and it was merged.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
   Change subject: {$change.subject}{\n}
diff --git a/resources/com/google/gerrit/server/mail/MergedHtml.soy b/resources/com/google/gerrit/server/mail/MergedHtml.soy
index 414479e..b11c5e5 100644
--- a/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -19,16 +19,11 @@
 /**
  * @param diffLines
  * @param email
- * @param fromEmail
  * @param fromName
- * @param patchSetInfo
  */
 {template .MergedHtml}
   <p>
-    {$fromName} <strong>merged</strong> this change
-    {if $patchSetInfo.authorEmail != $fromEmail}
-      {sp}by {$patchSetInfo.authorName}
-    {/if}.
+    {$fromName} <strong>merged</strong> this change.
   </p>
 
   {if $email.changeUrl}
diff --git a/resources/com/google/gerrit/server/mail/NoReplyFooter.soy b/resources/com/google/gerrit/server/mail/NoReplyFooter.soy
new file mode 100644
index 0000000..1443100
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/NoReplyFooter.soy
@@ -0,0 +1,23 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .NoReplyFooter kind="text"}
+  {\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/NoReplyFooterHtml.soy b/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy
new file mode 100644
index 0000000..93df527
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/NoReplyFooterHtml.soy
@@ -0,0 +1,24 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .NoReplyFooterHtml}
+  <p>
+    This is a send-only email address.  Replies to this message will not be read
+    or answered.
+  </p>
+{/template}
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
index a733b0c..b09a608 100644
--- a/tools/bzl/gwt.bzl
+++ b/tools/bzl/gwt.bzl
@@ -12,8 +12,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# Port of Buck native gwt_binary() rule. See discussion in context of
-# https://github.com/facebook/buck/issues/109
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:java.bzl", "java_library2")
 
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 1c8db72..79aed37 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -6,8 +6,8 @@
 
 import argparse
 from collections import defaultdict
-from shutil import copyfileobj
 from sys import stdout, stderr
+import os
 import xml.etree.ElementTree as ET
 
 
@@ -113,8 +113,8 @@
   print()
   print("[[%s_license]]" % safename)
   print("----")
-  with open(n[2:].replace(":", "/")) as fd:
-    copyfileobj(fd, stdout)
+  with open(n[2:].replace(":", "/"), "rb") as input:
+    os.write(stdout.fileno(), input.read(-1))
   print()
   print("----")
   print()
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
index 3578173..38dfbe5 100644
--- a/tools/bzl/license.bzl
+++ b/tools/bzl/license.bzl
@@ -25,7 +25,7 @@
   # post process the XML into our favorite format.
   native.genrule(
     name = "gen_license_txt_" + name,
-    cmd = "python2 $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
+    cmd = "python $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
     outs = [ name + ".txt" ],
     tools = tools,
     **kwargs
diff --git a/tools/download_file.py b/tools/download_file.py
index 1a32bbf..26671f0 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -25,8 +25,7 @@
 from zipfile import ZipFile, BadZipfile, LargeZipFile
 
 GERRIT_HOME = path.expanduser('~/.gerritcodereview')
-# TODO(davido): Rename in bazel-cache
-CACHE_DIR = path.join(GERRIT_HOME, 'buck-cache', 'downloaded-artifacts')
+CACHE_DIR = path.join(GERRIT_HOME, 'bazel-cache', 'downloaded-artifacts')
 LOCAL_PROPERTIES = 'local.properties'
 
 
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 9f8b4b7..9374d88 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -13,7 +13,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-# TODO(sop): Remove hack after Buck supports Eclipse
 
 from __future__ import print_function
 # TODO(davido): use Google style for importing instead:
diff --git a/tools/js/bower2bazel.py b/tools/js/bower2bazel.py
index 7479021..ccdf2df 100755
--- a/tools/js/bower2bazel.py
+++ b/tools/js/bower2bazel.py
@@ -58,11 +58,13 @@
   "neon-animation": "polymer",
   "page": "page.js",
   "paper-button": "polymer",
+  "paper-icon-button": "polymer",
   "paper-input": "polymer",
   "paper-item": "polymer",
   "paper-listbox": "polymer",
   "paper-toggle-button": "polymer",
   "paper-styles": "polymer",
+  "paper-tabs": "polymer",
   "polymer": "polymer",
   "polymer-resin": "polymer",
   "promise-polyfill": "promise-polyfill",
diff --git a/tools/js/download_bower.py b/tools/js/download_bower.py
index 1c8d45e..3db39d5 100755
--- a/tools/js/download_bower.py
+++ b/tools/js/download_bower.py
@@ -26,7 +26,7 @@
 import bowerutil
 
 CACHE_DIR = os.path.expanduser(os.path.join(
-    '~', '.gerritcodereview', 'buck-cache', 'downloaded-artifacts'))
+    '~', '.gerritcodereview', 'bazel-cache', 'downloaded-artifacts'))
 
 
 def bower_cmd(bower, *args):
diff --git a/tools/maven/mvn.py b/tools/maven/mvn.py
index 2e1c1a9..50c4ac6 100755
--- a/tools/maven/mvn.py
+++ b/tools/maven/mvn.py
@@ -16,7 +16,7 @@
 from __future__ import print_function
 from optparse import OptionParser
 from os import path, environ
-from subprocess import check_output
+from subprocess import check_output, CalledProcessError
 from sys import stderr
 
 opts = OptionParser()
@@ -67,6 +67,8 @@
   except Exception as e:
     print('%s command failed: %s\n%s' % (args.a, ' '.join(exe), e),
       file=stderr)
+    if environ.get('VERBOSE') and isinstance(e, CalledProcessError):
+      print('Command output\n%s' % e.output, file=stderr)
     exit(1)