Merge "Discard request HTTP bodies before writing response"
diff --git a/.gitignore b/.gitignore
index e702ac9..1b93f20 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
 .DS_Store
 .gwt_work_dir
 /.apt_generated
+/.bazel_path
 /.buckd
 /.classpath
 /.factorypath
diff --git a/.gitmodules b/.gitmodules
index 6c4d53c..ec8afee 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -3,11 +3,6 @@
 	url = ../plugins/commit-message-length-validator
 	branch = .
 
-[submodule "plugins/cookbook-plugin"]
-	path = plugins/cookbook-plugin
-	url = ../plugins/cookbook-plugin
-	branch = .
-
 [submodule "plugins/download-commands"]
 	path = plugins/download-commands
 	url = ../plugins/download-commands
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index f64f739..20d4e45 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -850,6 +850,15 @@
 Note that this permission is named `submitAs` in the `project.config`
 file.
 
+[[category_view_private_changes]]
+=== View Private Changes
+
+This category permits users to view all private changes.
+
+The change owner and any explicitly added reviewers can always see
+private changes (even without having the `View Private Changes` access
+right assigned).
+
 [[category_view_drafts]]
 === View Drafts
 
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index b0a07c7..6653031 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -397,8 +397,14 @@
 the "Switch Account" link is displayed next to "Sign Out".
 +
 When `auth.type` does not normally enable this URL administrators may
-set this to `login/` or `$canonicalWebUrl/login`, allowing users to
-begin a new web session.
+set this to `login/`, allowing users to begin a new web session. This value
+is used as an href in PolyGerrit and the GWT UI, so absolute URLs like
+`https://someotherhost/login` work as well.
++
+If a ${path} parameter is included, then PolyGerrit will substitute the
+currently viewed path in the link. Be aware that this path will include
+a leading slash, so a value like this might be appropriate: `/login${path}`.
+Note: in the GWT UI this substitution for ${path} is *always* `/`.
 
 [[auth.cookiePath]]auth.cookiePath::
 +
@@ -819,10 +825,6 @@
 is per-user, so 1024 items translates to 1024 unique user accounts.
 As each individual user account may configure multiple SSH keys,
 the total number of keys may be larger than the item count.
-+
-This cache is based off the `account_ssh_keys` table and the
-`accounts.ssh_user_name` column in the database.  If either is
-modified directly, this cache should be flushed.
 
 cache `"web_sessions"`::
 +
@@ -1543,6 +1545,10 @@
 +
 Connect to a MySQL database server.
 +
+* `MARIADB`
++
+Connect to a MariaDB database server.
++
 * `ORACLE`
 +
 Connect to an Oracle database server.
@@ -3119,6 +3125,13 @@
   safe = true
 ----
 
+[[note-db]]
+=== Section noteDb
+
+NoteDb is the next generation of Gerrit storage backend, currently powering
+`googlesource.com`. It is not (yet) recommended for general use, but if you want
+to learn more, see the link:dev-note-db.html[developer documentation].
+
 [[oauth]]
 === Section oauth
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 34f39c8..0cafc13 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -291,6 +291,18 @@
 check. If the `branchOrder` section is not defined then the mergeability of a
 change into other branches will not be done.
 
+[[reviewer-section]]
+=== reviewer section
+
+Defines config options to adjust a project's reviewer workflow such as enabling
+reviewers and CCs by email.
+
+[[reviewer.enableByEmail]]reviewer.enableByEmail::
++
+A boolean indicating if reviewers and CCs that do not currently have a Gerrit
+account can be added to a change by providing their email address.
+
+Defaults to `false`.
 
 [[file-groups]]
 == The file +groups+
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
index 8667f43..3fd0c91 100644
--- a/Documentation/database-setup.txt
+++ b/Documentation/database-setup.txt
@@ -74,6 +74,14 @@
 Visit MySQL's link:http://dev.mysql.com/doc/[documentation] for further
 information regarding using MySQL.
 
+[[createdb_mariadb]]
+=== MariaDB
+
+Refer to MySQL section above how to create MariaDB database.
+
+Visit MariaDB's link:https://mariadb.com/kb/en/mariadb/[documentation] for further
+information regarding using MariaDB.
+
 [[createdb_oracle]]
 === Oracle
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 7c35b12..60a56dd 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -202,12 +202,31 @@
   bazel test //gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account:rest_account
 ----
 
-To run the tests against NoteDb backend:
+To run the tests against NoteDb backend with write
+to NoteDb, but not read from it:
+
+----
+  bazel test --test_env=GERRIT_NOTEDB=WRITE //...
+----
+
+Write and read from NoteDb:
 
 ----
   bazel test --test_env=GERRIT_NOTEDB=READ_WRITE //...
 ----
 
+Primary storage NoteDb:
+
+----
+  bazel test --test_env=GERRIT_NOTEDB=PRIMARY //...
+----
+
+Primary storage NoteDb and ReviewDb disabled:
+
+----
+  bazel test --test_env=GERRIT_NOTEDB=DISABLE_CHANGE_REVIEW_DB //...
+----
+
 To run only tests that do not use SSH:
 
 ----
@@ -333,6 +352,11 @@
  )
 ----
 
+[[consume-jgit-from-development-tree]]
+
+To consume the JGit dependency from the development tree, edit
+`lib/jgit/jgit.bzl` setting LOCAL_JGIT_REPO to a directory holding a
+JGit repository.
 
 [[clean-cache]]
 === Cleaning The download cache
diff --git a/Documentation/dev-note-db.txt b/Documentation/dev-note-db.txt
index 0db3785..dd3b316 100644
--- a/Documentation/dev-note-db.txt
+++ b/Documentation/dev-note-db.txt
@@ -27,14 +27,23 @@
   servers behind `googlesource.com`. In other words, if you use
   link:https://gerrit-review.googlesource.com/[gerrit-review], you're already
   using NoteDb. +
-  Specifically, `gerrit-review` is running with `noteDb.changes.write=true`,
-  `noteDb.changes.read=true`, `noteDb.changes.primaryStorage=NOTE_DB`, and all
-  old changes have been migrated to NoteDb primary.
 - Storing some account data, e.g. user preferences, is implemented in releases
   back to 2.13.
 - Storing the rest of account data is a work in progress.
 - Storing group data is a work in progress.
 
+To match the current configuration of `googlesource.com`, paste the following
+config snippet in your `gerrit.config`:
+
+----
+[noteDb "changes"]
+  write = true
+  read = true
+  primaryStorage = NOTE_DB
+  disableReviewDb = true
+----
+
+
 For an example NoteDb change, poke around at this one:
 ----
   git fetch https://gerrit.googlesource.com/gerrit refs/changes/70/98070/meta \
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 0c5511e..b7129db 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -482,9 +482,50 @@
   $ git push origin HEAD:refs/heads/master -o topic=multi-master
 ----
 
+[[private changes]]
+== Private changes
+
+Private changes are changes that are only visible to their owners and
+reviewers. Private changes are useful in a number of cases:
+
+* You want to check what the change looks before formal review starts.
+  By marking the change private without reviewers, nobody can't
+  prematurely comment on your changes.
+
+* You want to use Gerrit to sync data between different devices. By
+  creating a private throwaway change without reviewers, you can push
+  from one device, and fetch to another device.
+
+* You want to do code review on a change that has sensitive
+  aspects. By reviewing a security fix in a private change,
+  outsiders can't discover the fix before it is pushed out. Even after
+  merging the change, the review can be kept private.
+
+To create a private change, you push it with the `private` option.
+
+.Push a private change
+----
+  $ git commit
+  $ git push origin HEAD:refs/for/master%private
+----
+
+The change will remain private on subsequent pushes until you specify
+the `remove-private` option. Alternatively, the web UI provides buttons
+to mark a change private and non-private again.
+
+For CI systems that must verify private changes, a special permission
+can be granted
+(link:access-control.html#category_view_private_changes[View Private Changes]).
+In that case, care should be taken to prevent the CI system from
+exposing secret details.
+
+
 [[drafts]]
 == Working with Drafts
 
+Drafts is a deprecated feature and will be removed soon. Consider using
+private changes instead.
+
 Changes can be uploaded as drafts. By default draft changes are only
 visible to the change owner. This gives you the possibility to have
 some staging before making your changes visible to the reviewers. Draft
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 45648e5..85fcfd9 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2107,6 +2107,47 @@
   }
 ----
 
+[[Mark-private]]
+=== Mark Private
+--
+'PUT /changes/link:#change-id[\{change-id\}]/private'
+--
+
+Marks the change to be private. Note users can only mark own changes as private.
+
+.Request
+----
+  Set /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 201 Created
+----
+
+If the change was already private the response is "`200 OK`".
+
+[[unmark-private]]
+=== Unmark Private
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/private'
+--
+
+Marks the change to be non-private. Note users can only unmark own private
+changes.
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/private HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+If the change was already not private, the response is "`409 Conflict`".
+
 [[edit-endpoints]]
 == Change Edit Endpoints
 
@@ -2764,6 +2805,41 @@
   }
 ----
 
+If link:config-project-config.html#reviewer.enableByEmail[reviewer.enableByEmail] is set
+for the project, reviewers and CCs are not required to have a Gerrit account. If you POST
+an email address of a reviewer or CC then, they will be added to the change even if they
+don't have a Gerrit account.
+
+If this option is disabled, the request would fail with `400 Bad Request` if the email
+address can't be resolved to an active Gerrit account.
+
+Note that the name is optional so both "un.registered@reviewer.com" and
+"John Doe <un.registered@reviewer.com>" are valid inputs.
+
+Reviewers without Gerrit accounts can only be added on changes visible to anonymous users.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reviewer": "John Doe <un.registered@reviewer.com>"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "input": "John Doe <un.registered@reviewer.com>"
+  }
+----
+
 [[delete-reviewer]]
 === Delete Reviewer
 --
@@ -4264,24 +4340,12 @@
 
 Lists the files that were modified, added or deleted in a revision.
 
-In addition the following magic files are included:
-
-* `/COMMIT_MSG`:
-+
-The commit message and headers with the parent commit(s), the author
-information and the committer information.
-
-* `/MERGE_LIST` (for merge commits only):
-+
-The list of commits that are being integrated into the destination
-branch by submitting the merge commit.
-
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/ HTTP/1.0
 ----
 
-As result a map is returned that maps the file path to a list of
+As result a map is returned that maps the link:#file-id[file path] to a list of
 link:#file-info[FileInfo] entries. The entries in the map are
 sorted by file path.
 
@@ -4722,7 +4786,8 @@
 Cherry picks a revision to a destination branch.
 
 The commit message and destination branch must be provided in the request body inside a
-link:#cherrypick-input[CherryPickInput] entity.
+link:#cherrypick-input[CherryPickInput] entity.  If the commit message
+does not specify a Change-Id, a new one is picked for the destination change.
 
 .Request
 ----
@@ -4926,6 +4991,18 @@
 === \{file-id\}
 The path of the file.
 
+The following magic paths are supported:
+
+* `/COMMIT_MSG`:
++
+The commit message and headers with the parent commit(s), the author
+information and the committer information.
+
+* `/MERGE_LIST` (for merge commits only):
++
+The list of commits that are being integrated into the destination
+branch by submitting the merge commit.
+
 [[fix-id]]
 === \{fix-id\}
 UUID of a suggested fix.
@@ -5259,6 +5336,10 @@
 Author of the message as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity. +
 Unset if written by the Gerrit system.
+|`real_author`         |optional|
+Real author of the message as an
+link:rest-api-accounts.html#account-info[AccountInfo] entity. +
+Set if the message was posted on behalf of another user.
 |`date`            ||
 The link:rest-api.html#timestamp[timestamp] this message was posted.
 |`message`            ||The text left by the user.
@@ -5386,10 +5467,10 @@
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name    ||Description
-|`start_line`        ||The start line number of the range.
-|`start_character`   ||The character position in the start line.
-|`end_line`          ||The end line number of the range.
-|`end_character`     ||The character position in the end line.
+|`start_line`        ||The start line number of the range. (1-based, inclusive)
+|`start_character`   ||The character position in the start line. (0-based, inclusive)
+|`end_line`          ||The end line number of the range. (1-based, exclusive)
+|`end_character`     ||The character position in the end line. (0-based, exclusive)
 |===========================
 
 [[commit-info]]
@@ -6138,6 +6219,10 @@
 |`approvals`   |
 The approvals of the reviewer as a map that maps the label names to the
 approval values ("`-2`", "`-1`", "`0`", "`+1`", "`+2`").
+|`_account_id`   |
+This field is inherited from `AccountInfo` but is optional here if an
+unregistered reviewer was added by email. See
+link:rest-api-changes.html#add-reviewer[add-reviewer] for details.
 |==========================
 
 [[reviewer-input]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 72c6a39..17b0192 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2060,6 +2060,60 @@
   Ly8gQ29weXJpZ2h0IChDKSAyMDEwIFRoZSBBbmRyb2lkIE9wZW4gU291cmNlIFByb2plY...
 ----
 
+
+[[cherry-pick-commit]]
+=== Cherry Pick Commit
+--
+'POST /projects/link:#project-name[\{project-name\}]/commits/link:#commit-id[\{commit-id\}]/cherrypick'
+--
+
+Cherry-picks a commit of a project to a destination branch.
+
+The destination branch must be provided in the request body inside a
+link:rest-api-changes.html#cherrypick-input[CherryPickInput] entity.
+If the commit message is not set, the commit message of the source
+commit will be used.
+
+.Request
+----
+  POST /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/cherrypick HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message" : "Implementing Feature X",
+    "destination" : "release-branch"
+  }
+----
+
+As response a link:rest-api-changes.html#change-info[ChangeInfo] entity is returned that
+describes the resulting cherry-picked change.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "project": "myProject",
+    "branch": "release-branch",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "subject": "Implementing Feature X",
+    "status": "NEW",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "mergeable": true,
+    "insertions": 12,
+    "deletions": 11,
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    }
+  }
+----
+
 [[dashboard-endpoints]]
 == Dashboard Endpoints
 
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 9775c40..ae2fcca 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -350,6 +350,16 @@
 +
 True if the change has no merge conflicts and could be merged into its
 destination branch.
++
+Mergeability of abandoned changes is not computed. This operator will
+not find any abandoned but mergeable changes.
+
+
+[[private]]
+is:private::
++
+True if the change is private, ie. only visible to owner and its
+reviewers.
 
 [[status]]
 status:open, status:pending::
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 9efbb21..8f716d1 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -212,6 +212,24 @@
   git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/experimental -o topic=driver/i42
 ----
 
+[[private]]
+==== Private Changes
+
+To push a private change or to turn a change private on push the `private`
+option can be specified:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%private
+----
+
+Omitting the `private` option when pushing updates to a private change
+doesn't make change non-private again. To remove the private
+flag from a change on push, explicitly specify the `remove-private` option:
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master%remove-private
+----
+
 [[message]]
 ==== Message
 
diff --git a/WORKSPACE b/WORKSPACE
index a74a29d..c503f91 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -130,24 +130,9 @@
     sha1 = "cdb2dcb4e22b83d6b32b93095f644c3462739e82",
 )
 
-load("//lib/jgit:jgit.bzl", "JGIT_VERS", "JGIT_REPO", "JGIT_SHA1", "JGIT_SRC_SHA1", "JGIT_SERVLET_SHA1", "JGIT_ARCHIVE_SHA1", "JGIT_JUNIT_SHA1")
+load("//lib/jgit:jgit.bzl", "jgit_repos")
 
-maven_jar(
-    name = "jgit",
-    artifact = "org.eclipse.jgit:org.eclipse.jgit:" + JGIT_VERS,
-    repository = JGIT_REPO,
-    sha1 = JGIT_SHA1,
-    src_sha1 = JGIT_SRC_SHA1,
-    unsign = True,
-)
-
-maven_jar(
-    name = "jgit_servlet",
-    artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + JGIT_VERS,
-    repository = JGIT_REPO,
-    sha1 = JGIT_SERVLET_SHA1,
-    unsign = True,
-)
+jgit_repos()
 
 maven_jar(
     name = "javaewah",
@@ -157,21 +142,6 @@
 )
 
 maven_jar(
-    name = "jgit_archive",
-    artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + JGIT_VERS,
-    repository = JGIT_REPO,
-    sha1 = JGIT_ARCHIVE_SHA1,
-)
-
-maven_jar(
-    name = "jgit_junit",
-    artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + JGIT_VERS,
-    repository = JGIT_REPO,
-    sha1 = JGIT_JUNIT_SHA1,
-    unsign = True,
-)
-
-maven_jar(
     name = "gwtjsonrpc",
     artifact = "com.google.gerrit:gwtjsonrpc:1.11",
     sha1 = "0990e7eec9eec3a15661edcf9232acbac4aeacec",
@@ -180,27 +150,27 @@
 
 maven_jar(
     name = "gson",
-    artifact = "com.google.code.gson:gson:2.7",
-    sha1 = "751f548c85fa49f330cecbb1875893f971b33c4e",
+    artifact = "com.google.code.gson:gson:2.8.0",
+    sha1 = "c4ba5371a29ac9b2ad6129b1d39ea38750043eff",
 )
 
 maven_jar(
     name = "gwtorm_client",
-    artifact = "com.google.gerrit:gwtorm:1.17",
-    sha1 = "97bdc872f00388910c9af70771f07bbb32f1b949",
-    src_sha1 = "889e35d7295b1af49161a28daaea9905ffa76a63",
+    artifact = "com.google.gerrit:gwtorm:1.18",
+    sha1 = "f326dec463439a92ccb32f05b38345e21d0b5ecf",
+    src_sha1 = "e0b973d5cafef3d145fa80cdf032fcead1186d29",
 )
 
 maven_jar(
     name = "protobuf",
-    artifact = "com.google.protobuf:protobuf-java:2.5.0",
-    sha1 = "a10732c76bfacdbd633a7eb0f7968b1059a65dfa",
+    artifact = "com.google.protobuf:protobuf-java:3.0.0-beta-2",
+    sha1 = "de80fe047052445869b96f6def6baca7182c95af",
 )
 
 maven_jar(
     name = "joda_time",
-    artifact = "joda-time:joda-time:2.9.4",
-    sha1 = "1c295b462f16702ebe720bbb08f62e1ba80da41b",
+    artifact = "joda-time:joda-time:2.9.8",
+    sha1 = "03986e1763e5df02ad7fc040ecb555193a8436bb",
 )
 
 maven_jar(
@@ -287,8 +257,8 @@
 
 maven_jar(
     name = "commons_codec",
-    artifact = "commons-codec:commons-codec:1.4",
-    sha1 = "4216af16d38465bbab0f3dff8efa14204f7a399a",
+    artifact = "commons-codec:commons-codec:1.10",
+    sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
 )
 
 maven_jar(
@@ -299,8 +269,8 @@
 
 maven_jar(
     name = "commons_compress",
-    artifact = "org.apache.commons:commons-compress:1.12",
-    sha1 = "84caa68576e345eb5e7ae61a0e5a9229eb100d7b",
+    artifact = "org.apache.commons:commons-compress:1.13",
+    sha1 = "15c5e9584200122924e50203ae210b57616b75ee",
 )
 
 maven_jar(
@@ -353,8 +323,8 @@
 
 maven_jar(
     name = "pegdown",
-    artifact = "org.pegdown:pegdown:1.4.2",
-    sha1 = "d96db502ed832df867ff5d918f05b51ba3879ea7",
+    artifact = "org.pegdown:pegdown:1.6.0",
+    sha1 = "231ae49d913467deb2027d0b8a0b68b231deef4f",
 )
 
 maven_jar(
@@ -439,8 +409,8 @@
 
 maven_jar(
     name = "auto_value",
-    artifact = "com.google.auto.value:auto-value:1.4-rc3",
-    sha1 = "b58e82f70576a951146ca61a00ef26806f8c4667",
+    artifact = "com.google.auto.value:auto-value:1.4",
+    sha1 = "6d1448fcd13074bd3658ef915022410b7c48343b",
 )
 
 maven_jar(
@@ -595,8 +565,14 @@
 # Keep this version of Soy synchronized with the version used in Gitiles.
 maven_jar(
     name = "soy",
-    artifact = "com.google.template:soy:2016-08-09",
-    sha1 = "43d33651e95480d515fe26c10a662faafe3ad1e4",
+    artifact = "com.google.template:soy:2017-02-01",
+    sha1 = "8638940b207779fe3b75e55b6e65abbefb6af678",
+)
+
+maven_jar(
+    name = "html_types",
+    artifact = "com.google.common.html.types:types:1.0.4",
+    sha1 = "2adf4c8bfccc0ff7346f9186ac5aa57d829ad065",
 )
 
 maven_jar(
@@ -607,8 +583,8 @@
 
 maven_jar(
     name = "dropwizard_core",
-    artifact = "io.dropwizard.metrics:metrics-core:3.1.2",
-    sha1 = "224f03afd2521c6c94632f566beb1bb5ee32cf07",
+    artifact = "io.dropwizard.metrics:metrics-core:3.2.2",
+    sha1 = "cd9886f498ee2ab2d994f0c779e5553b2c450416",
 )
 
 # This version must match the version that also appears in
@@ -635,14 +611,14 @@
 
 maven_jar(
     name = "sshd",
-    artifact = "org.apache.sshd:sshd-core:1.2.0",
-    sha1 = "4bc24a8228ba83dac832680366cf219da71dae8e",
+    artifact = "org.apache.sshd:sshd-core:1.4.0",
+    sha1 = "c8f3d7457fc9979d1b9ec319f0229b89793c8e56",
 )
 
 maven_jar(
     name = "mina_core",
-    artifact = "org.apache.mina:mina-core:2.0.10",
-    sha1 = "a1cb1136b104219d6238de886bf5a3ea4554eb58",
+    artifact = "org.apache.mina:mina-core:2.0.16",
+    sha1 = "f720f17643eaa7b0fec07c1d7f6272972c02bba4",
 )
 
 maven_jar(
@@ -697,6 +673,13 @@
     sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
 )
 
+# Only needed when jgit is built from the development tree
+maven_jar(
+    name = "hamcrest_library",
+    artifact = "org.hamcrest:hamcrest-library:1.3",
+    sha1 = "4785a3c21320980282f9f33d0d1264a69040538f",
+)
+
 TRUTH_VERS = "0.32"
 
 maven_jar(
@@ -864,13 +847,13 @@
 maven_jar(
     name = "codemirror_minified",
     artifact = "org.webjars.npm:codemirror-minified:" + CM_VERSION,
-    sha1 = "27d5d8902b0c08c049f429575ff4f931e29d1664",
+    sha1 = "f84c178b11a188f416b4380bfb2b24f126453d28",
 )
 
 maven_jar(
     name = "codemirror_original",
     artifact = "org.webjars.npm:codemirror:" + CM_VERSION,
-    sha1 = "76088a0cdf869ae0935821ba6720b2e0ed3e9108",
+    sha1 = "5a1f6c10d5aef0b9d2ce513dcc1e2657e4af730d",
 )
 
 maven_jar(
@@ -900,23 +883,23 @@
 
 maven_jar(
     name = "elasticsearch",
-    artifact = "org.elasticsearch:elasticsearch:2.4.0",
-    sha1 = "aeb9704a76fa8654c348f38fcbb993a952a7ab07",
+    artifact = "org.elasticsearch:elasticsearch:2.4.4",
+    sha1 = "e69930bc794c539d34778e665d6f8ccbffd42c6f",
 )
 
 # Java REST client for Elasticsearch.
-JEST_VERSION = "2.0.3"
+JEST_VERSION = "2.4.0"
 
 maven_jar(
     name = "jest_common",
     artifact = "io.searchbox:jest-common:" + JEST_VERSION,
-    sha1 = "f304c66894aaf2f6c17a886bc826f09c7a161cf9",
+    sha1 = "ea779ebe7c438a53dce431f85b0d4e1d8faee2ac",
 )
 
 maven_jar(
     name = "jest",
     artifact = "io.searchbox:jest:" + JEST_VERSION,
-    sha1 = "b8f9ed1423489b361804e47f640515ea9f1fa08d",
+    sha1 = "e2a604a584e6633545ac6b1fe99ef888ab96dae9",
 )
 
 maven_jar(
diff --git a/gerrit-acceptance-framework/pom.xml b/gerrit-acceptance-framework/pom.xml
index d9d701c..d7c39ff 100644
--- a/gerrit-acceptance-framework/pom.xml
+++ b/gerrit-acceptance-framework/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>2.14-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 2131142..abdde61 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -88,6 +88,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.mail.Address;
 import com.google.gerrit.server.mail.send.EmailHeader;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -648,28 +649,32 @@
       String changeId, String ref, TestAccount testAccount, TestRepository<?> repo)
       throws Exception {
     Collections.shuffle(RANDOM);
-    return amendChange(changeId, ref, testAccount, repo, PushOneCommit.SUBJECT,
-        PushOneCommit.FILE_NAME, new String(Chars.toArray(RANDOM)));
+    return amendChange(
+        changeId,
+        ref,
+        testAccount,
+        repo,
+        PushOneCommit.SUBJECT,
+        PushOneCommit.FILE_NAME,
+        new String(Chars.toArray(RANDOM)));
   }
 
-  protected PushOneCommit.Result amendChange(String changeId, String subject, String fileName,
-      String content) throws Exception {
+  protected PushOneCommit.Result amendChange(
+      String changeId, String subject, String fileName, String content) throws Exception {
     return amendChange(changeId, "refs/for/master", admin, testRepo, subject, fileName, content);
   }
 
   protected PushOneCommit.Result amendChange(
-      String changeId, String ref, TestAccount testAccount, TestRepository<?> repo, String subject,
-      String fileName, String content)
+      String changeId,
+      String ref,
+      TestAccount testAccount,
+      TestRepository<?> repo,
+      String subject,
+      String fileName,
+      String content)
       throws Exception {
     PushOneCommit push =
-        pushFactory.create(
-            db,
-            testAccount.getIdent(),
-            repo,
-            subject,
-            fileName,
-            content,
-            changeId);
+        pushFactory.create(db, testAccount.getIdent(), repo, subject, fileName, content, changeId);
     return push.to(ref);
   }
 
@@ -1196,21 +1201,29 @@
   }
 
   protected void assertNotifyTo(TestAccount expected) {
+    assertNotifyTo(expected.emailAddress);
+  }
+
+  protected void assertNotifyTo(Address expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.rcpt()).containsExactly(expected);
     assertThat(((EmailHeader.AddressList) m.headers().get("To")).getAddressList())
-        .containsExactly(expected.emailAddress);
+        .containsExactly(expected);
     assertThat(m.headers().get("CC").isEmpty()).isTrue();
   }
 
   protected void assertNotifyCc(TestAccount expected) {
+    assertNotifyCc(expected.emailAddress);
+  }
+
+  protected void assertNotifyCc(Address expected) {
     assertThat(sender.getMessages()).hasSize(1);
     Message m = sender.getMessages().get(0);
-    assertThat(m.rcpt()).containsExactly(expected.emailAddress);
+    assertThat(m.rcpt()).containsExactly(expected);
     assertThat(m.headers().get("To").isEmpty()).isTrue();
     assertThat(((EmailHeader.AddressList) m.headers().get("CC")).getAddressList())
-        .containsExactly(expected.emailAddress);
+        .containsExactly(expected);
   }
 
   protected void assertNotifyBcc(TestAccount expected) {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
index e136bb3..1e793d8 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.testutil.SshMode;
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
index 58cdf96..7d1ebdc 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GerritServer.java
@@ -133,17 +133,14 @@
   static GerritServer start(Description desc, Config baseConfig) throws Exception {
     Config cfg = desc.buildConfig(baseConfig);
     Logger.getLogger("com.google.gerrit").setLevel(Level.DEBUG);
-    final CyclicBarrier serverStarted = new CyclicBarrier(2);
-    final Daemon daemon =
+    CyclicBarrier serverStarted = new CyclicBarrier(2);
+    Daemon daemon =
         new Daemon(
-            new Runnable() {
-              @Override
-              public void run() {
-                try {
-                  serverStarted.await();
-                } catch (InterruptedException | BrokenBarrierException e) {
-                  throw new RuntimeException(e);
-                }
+            () -> {
+              try {
+                serverStarted.await();
+              } catch (InterruptedException | BrokenBarrierException e) {
+                throw new RuntimeException(e);
               }
             },
             Paths.get(baseConfig.getString("gerrit", null, "tempSiteDir")));
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index e34223e8..abaaefc 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.gpg.testutil.TestKeys.validKeyWithoutExpiration;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -59,15 +60,15 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.testutil.TestKey;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountByEmailCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
 import com.google.gerrit.server.account.WatchConfig;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
@@ -116,6 +117,8 @@
 
   @Inject private AccountByEmailCache byEmailCache;
 
+  @Inject private ExternalIds externalIds;
+
   @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   private ExternalIdsUpdate externalIdsUpdate;
@@ -913,7 +916,11 @@
     Iterable<String> expectedFps =
         expected.transform(k -> BaseEncoding.base16().encode(k.getPublicKey().getFingerprint()));
     Iterable<String> actualFps =
-        GpgKeys.getGpgExtIds(db, currAccountId).transform(e -> e.key().id());
+        externalIds
+            .byAccount(db, currAccountId, SCHEME_GPGKEY)
+            .stream()
+            .map(e -> e.key().id())
+            .collect(toSet());
     assertThat(actualFps).named("external IDs in database").containsExactlyElementsIn(expectedFps);
 
     // Check raw stored keys.
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 3cc7a45..86b4d2d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -181,6 +181,70 @@
   }
 
   @Test
+  public void setPrivateByOwner() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    String changeId = result.getChangeId();
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isFalse();
+    gApi.changes().id(changeId).setPrivate(true);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isTrue();
+    gApi.changes().id(changeId).setPrivate(false);
+    assertThat(gApi.changes().id(changeId).get().isPrivate).isFalse();
+  }
+
+  @Test
+  public void setPrivateByOtherUser() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isFalse();
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to mark private");
+    gApi.changes().id(result.getChangeId()).setPrivate(true);
+  }
+
+  @Test
+  public void accessPrivate() throws Exception {
+    TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
+    PushOneCommit.Result result =
+        pushFactory.create(db, user.getIdent(), userRepo).to("refs/for/master");
+
+    setApiUser(user);
+    gApi.changes().id(result.getChangeId()).setPrivate(true);
+    // Owner can always access its private changes.
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Add admin as a reviewer.
+    gApi.changes().id(result.getChangeId()).addReviewer(admin.getId().toString());
+
+    // This change should be visible for admin as a reviewer.
+    setApiUser(admin);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+
+    // Remove admin from reviewers.
+    gApi.changes().id(result.getChangeId()).reviewer(admin.getId().toString()).remove();
+
+    // This change should not be visible for admin anymore.
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + result.getChangeId());
+    gApi.changes().id(result.getChangeId());
+  }
+
+  @Test
+  public void privateChangeOfOtherUserCanBeAccessedWithPermission() throws Exception {
+    PushOneCommit.Result result = createChange();
+    gApi.changes().id(result.getChangeId()).setPrivate(true);
+
+    allow(Permission.VIEW_PRIVATE_CHANGES, REGISTERED_USERS, "refs/*");
+    setApiUser(user);
+    assertThat(gApi.changes().id(result.getChangeId()).get().isPrivate).isTrue();
+  }
+
+  @Test
   public void getAmbiguous() throws Exception {
     PushOneCommit.Result r1 = createChange();
     String changeId = r1.getChangeId();
@@ -1563,7 +1627,7 @@
             Iterables.getOnlyElement(query("project:{" + project.get() + "} owner:self")).changeId)
         .isEqualTo(r.getChangeId());
     setApiUser(user);
-    assertThat(query("owner:self")).isEmpty();
+    assertThat(query("owner:self project:{" + project.get() + "}")).isEmpty();
   }
 
   @Test
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
index a395132..7752f3e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -99,6 +99,17 @@
   }
 
   @Test
+  public void addMembersWithAtSign() throws Exception {
+    String g = createGroup("users");
+    TestAccount u10 = accounts.create("u10", "u10@example.com", "Full Name 10");
+    TestAccount u11_at =
+        accounts.create("u11@something", "u11@example.com", "Full Name 11 With At");
+    accounts.create("u11", "u11.another@example.com", "Full Name 11 Without At");
+    gApi.groups().id(g).addMembers(u10.username, u11_at.username);
+    assertMembers(g, u10, u11_at);
+  }
+
+  @Test
   public void includeRemoveGroup() throws Exception {
     String p = createGroup("parent");
     String g = createGroup("newGroup");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 577634e..7be0b024 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -326,6 +326,28 @@
   }
 
   @Test
+  public void cherryPickSetChangeId() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    String id = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbe3f";
+    in.message = "it goes to foo branch\n\nChange-Id: " + id;
+
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
+
+    assertThat(orig.get().messages).hasSize(1);
+    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
+
+    ChangeInfo changeInfo = cherry.get();
+
+    // The cherry-pick honors the ChangeId specified in the input message:
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).endsWith(id + "\n");
+  }
+
+  @Test
   public void cherryPickwithNoTopic() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     CherryPickInput in = new CherryPickInput();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 31ca9df..0ed5d8d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -376,6 +376,38 @@
   }
 
   @Test
+  public void pushPrivateChange() throws Exception {
+    // Push a private change.
+    PushOneCommit.Result r = pushTo("refs/for/master%private");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Pushing a new patch set without --private doesn't remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Remove the privacy flag from the change.
+    r = amendChange(r.getChangeId(), "refs/for/master%remove-private");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Normal push: privacy flag is not added back.
+    r = amendChange(r.getChangeId(), "refs/for/master");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isFalse();
+
+    // Make the change private again.
+    r = pushTo("refs/for/master%private");
+    r.assertOkStatus();
+    assertThat(r.getChange().change().isPrivate()).isTrue();
+
+    // Can't use --private and --remove-private together.
+    r = pushTo("refs/for/master%private,remove-private");
+    r.assertErrorStatus();
+  }
+
+  @Test
   public void pushForMasterAsDraft() throws Exception {
     // create draft by pushing to 'refs/drafts/'
     PushOneCommit.Result r = pushTo("refs/drafts/master");
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index b900cc7..3282117 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
@@ -54,6 +55,8 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.LsRemoteCommand;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -470,6 +473,46 @@
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(c4, 1));
   }
 
+  @Test
+  public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/heads/master");
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      LsRemoteCommand lsRemoteCommand = git.lsRemote();
+      String change3RefName = c3.currentPatchSet().getRefName();
+
+      List<String> initialRefNames =
+          lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertWithMessage("Precondition violated").that(initialRefNames).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true);
+
+      List<String> refNames = lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertThat(refNames).doesNotContain(change3RefName);
+    }
+  }
+
+  @Test
+  public void advertisedReferencesIncludePrivateChangesWhenAllRefsMayBeRead() throws Exception {
+    allow(Permission.READ, REGISTERED_USERS, "refs/*");
+
+    TestRepository<?> userTestRepository = cloneProject(project, user);
+    try (Git git = userTestRepository.git()) {
+      LsRemoteCommand lsRemoteCommand = git.lsRemote();
+      String change3RefName = c3.currentPatchSet().getRefName();
+
+      List<String> initialRefNames =
+          lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertWithMessage("Precondition violated").that(initialRefNames).contains(change3RefName);
+
+      gApi.changes().id(c3.getId().get()).setPrivate(true);
+
+      List<String> refNames = lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
+      assertThat(refNames).contains(change3RefName);
+    }
+  }
+
   /**
    * Assert that refs seen by a non-admin user match expected.
    *
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 06b8f68..4039284 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.junit.Assert.fail;
 
@@ -32,9 +32,10 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIds;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gson.reflect.TypeToken;
@@ -51,6 +52,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
 @Sandboxed
@@ -173,7 +175,7 @@
 
   @Test
   public void retryOnLockFailure() throws Exception {
-    Retryer<Void> retryer =
+    Retryer<ObjectId> retryer =
         ExternalIdsUpdate.retryerBuilder()
             .withBlockStrategy(
                 new BlockStrategy() {
@@ -192,6 +194,8 @@
         new ExternalIdsUpdate(
             repoManager,
             allUsers,
+            externalIds,
+            new DisabledExternalIdCache(),
             serverIdent.get(),
             serverIdent.get(),
             () -> {
@@ -208,8 +212,8 @@
     update.insert(db, ExternalId.create(fooId, admin.id));
     assertThat(doneBgUpdate.get()).isTrue();
 
-    assertThat(externalIds.get(fooId)).isNotNull();
-    assertThat(externalIds.get(barId)).isNotNull();
+    assertThat(externalIds.get(db, fooId)).isNotNull();
+    assertThat(externalIds.get(db, barId)).isNotNull();
   }
 
   @Test
@@ -224,6 +228,8 @@
         new ExternalIdsUpdate(
             repoManager,
             allUsers,
+            externalIds,
+            new DisabledExternalIdCache(),
             serverIdent.get(),
             serverIdent.get(),
             () -> {
@@ -235,7 +241,7 @@
                 // Ignore, the successful insertion of the external ID is asserted later
               }
             },
-            RetryerBuilder.<Void>newBuilder()
+            RetryerBuilder.<ObjectId>newBuilder()
                 .retryIfException(e -> e instanceof LockFailureException)
                 .withStopStrategy(StopStrategies.stopAfterAttempt(extIdsKeys.length))
                 .build());
@@ -248,7 +254,7 @@
     }
     assertThat(bgCounter.get()).isEqualTo(extIdsKeys.length);
     for (ExternalId.Key extIdKey : extIdsKeys) {
-      assertThat(externalIds.get(extIdKey)).isNotNull();
+      assertThat(externalIds.get(db, extIdKey)).isNotNull();
     }
   }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index c69391c..7d9663a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -39,8 +39,10 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -62,6 +64,7 @@
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.util.EnumSet;
 import org.apache.http.Header;
 import org.apache.http.message.BasicHeader;
 import org.junit.After;
@@ -529,6 +532,28 @@
     assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
   }
 
+  @Test
+  public void changeMessageCreatedOnBehalfOfHasRealUser() throws Exception {
+    allowCodeReviewOnBehalfOf();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+    in.label("Code-Review", 1);
+
+    setApiUser(accounts.user2());
+    gApi.changes().id(r.getChangeId()).revision(r.getPatchSetId().getId()).review(in);
+
+    ChangeInfo info =
+        gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.MESSAGES));
+    assertThat(info.messages).hasSize(2);
+
+    ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
+    assertThat(changeMessageInfo.realAuthor).isNotNull();
+    assertThat(changeMessageInfo.realAuthor._accountId).isEqualTo(accounts.user2().id.get());
+  }
+
   private void allowCodeReviewOnBehalfOf() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     LabelType codeReviewType = Util.codeReview();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 77ca14f..d8aa35c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -419,8 +419,8 @@
     RevCommit headAfterChange1 = change1.getCommit();
     PushOneCommit.Result change2 = createChange("subject 2", "fileName 2", "content 2");
     testRepo.reset(headAfterChange1);
-    change1 = amendChange(change1.getChangeId(), "subject 1 amend", "fileName 2",
-        "rework content 2");
+    change1 =
+        amendChange(change1.getChangeId(), "subject 1 amend", "fileName 2", "rework content 2");
     submit(change1.getChangeId());
     headAfterChange1 = getRemoteHead();
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
new file mode 100644
index 0000000..bc841ad
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -0,0 +1,274 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
+import java.util.EnumSet;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeReviewersByEmailIT extends AbstractDaemonTest {
+
+  @Before
+  public void setUp() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.setEnableReviewerByEmail(true);
+    saveProjectConfig(project, cfg);
+  }
+
+  @Test
+  public void addByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      ChangeInfo info =
+          gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      // All reviewers added by email should be removable
+      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(acc));
+    }
+  }
+
+  @Test
+  public void removeByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      gApi.changes().id(r.getChangeId()).reviewer(acc.email).remove();
+
+      ChangeInfo info =
+          gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+      assertThat(info.reviewers).isEmpty();
+    }
+  }
+
+  @Test
+  public void convertFromCCToReviewer() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    PushOneCommit.Result r = createChange();
+
+    AddReviewerInput addInput = new AddReviewerInput();
+    addInput.reviewer = toRfcAddressString(acc);
+    addInput.state = ReviewerState.CC;
+    gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+    AddReviewerInput modifyInput = new AddReviewerInput();
+    modifyInput.reviewer = addInput.reviewer;
+    modifyInput.state = ReviewerState.REVIEWER;
+    gApi.changes().id(r.getChangeId()).addReviewer(modifyInput);
+
+    ChangeInfo info =
+        gApi.changes().id(r.getChangeId()).get(EnumSet.of(ListChangesOption.DETAILED_LABELS));
+    assertThat(info.reviewers)
+        .isEqualTo(ImmutableMap.of(ReviewerState.REVIEWER, ImmutableList.of(acc)));
+  }
+
+  @Test
+  public void addedReviewersGetNotified() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt()).containsExactly(Address.parse(input.reviewer));
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void removingReviewerTriggersNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput addInput = new AddReviewerInput();
+      addInput.reviewer = toRfcAddressString(acc);
+      addInput.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(addInput);
+
+      // Review change as user
+      ReviewInput reviewInput = new ReviewInput();
+      reviewInput.message = "I have a comment";
+      setApiUser(user);
+      revision(r).review(reviewInput);
+      setApiUser(admin);
+
+      sender.clear();
+
+      // Delete as admin
+      gApi.changes().id(r.getChangeId()).reviewer(addInput.reviewer).remove();
+
+      List<Message> messages = sender.getMessages();
+      assertThat(messages).hasSize(1);
+      assertThat(messages.get(0).rcpt())
+          .containsExactly(Address.parse(addInput.reviewer), user.emailAddress);
+      sender.clear();
+    }
+  }
+
+  @Test
+  public void reviewerAndCCReceiveRegularNotification() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+      sender.clear();
+
+      gApi.changes()
+          .id(r.getChangeId())
+          .revision(r.getCommit().name())
+          .review(ReviewInput.approve());
+
+      if (state == ReviewerState.CC) {
+        assertNotifyCc(Address.parse(input.reviewer));
+      } else {
+        assertNotifyTo(Address.parse(input.reviewer));
+      }
+    }
+  }
+
+  @Test
+  public void rejectMissingEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("email invalid");
+    gApi.changes().id(r.getChangeId()).addReviewer("");
+  }
+
+  @Test
+  public void rejectMalformedEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createChange();
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("email invalid");
+    gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
+  }
+
+  @Test
+  public void rejectOnNonPublicChange() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    PushOneCommit.Result r = createDraftChange();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("change is not publicly visible");
+    gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
+  }
+
+  @Test
+  public void rejectWhenFeatureIsDisabled() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.setEnableReviewerByEmail(false);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage(
+        "Foo Bar <foo.bar@gerritcodereview.com> does not identify a registered user or group");
+    gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
+  }
+
+  @Test
+  public void reviewersByEmailAreServedFromIndex() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+    AccountInfo acc = new AccountInfo("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
+      PushOneCommit.Result r = createChange();
+
+      AddReviewerInput input = new AddReviewerInput();
+      input.reviewer = toRfcAddressString(acc);
+      input.state = state;
+      gApi.changes().id(r.getChangeId()).addReviewer(input);
+
+      notesMigration.setFailOnLoad(true);
+      try {
+        ChangeInfo info =
+            Iterables.getOnlyElement(
+                gApi.changes()
+                    .query(r.getChangeId())
+                    .withOption(ListChangesOption.DETAILED_LABELS)
+                    .get());
+        assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      } finally {
+        notesMigration.setFailOnLoad(false);
+      }
+    }
+  }
+
+  private static String toRfcAddressString(AccountInfo info) {
+    return (new Address(info.name, info.email)).toString();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 66966c3..846c580 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
@@ -42,6 +43,7 @@
 import com.google.gson.stream.JsonReader;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -655,6 +657,30 @@
     assertThat(reviewerResult.ccs).hasSize(1);
   }
 
+  @Test
+  public void removingReviewerRemovesTheirVote() throws Exception {
+    String crLabel = "Code-Review";
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve().reviewer(admin.email);
+    ReviewResult addResult = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(addResult.reviewers).isNotNull();
+    assertThat(addResult.reviewers).hasSize(1);
+
+    Map<String, LabelInfo> changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).hasSize(1);
+
+    RestResponse deleteResult = deleteReviewer(r.getChangeId(), admin);
+    deleteResult.assertNoContent();
+
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+
+    // Check that the vote is gone even after the reviewer is added back
+    addReviewer(r.getChangeId(), admin.email);
+    changeLabels = getChangeLabels(r.getChangeId());
+    assertThat(changeLabels.get(crLabel).all).isNull();
+  }
+
   private AddReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
     return addReviewer(changeId, reviewer, SC_OK);
   }
@@ -735,4 +761,8 @@
     }
     return result;
   }
+
+  private Map<String, LabelInfo> getChangeLabels(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(EnumSet.of(DETAILED_LABELS)).labels;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index f79b5fa..146b5ca 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -23,6 +23,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
@@ -30,11 +31,15 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -45,10 +50,13 @@
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TestTimeUtil;
+import java.util.Iterator;
 import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -278,6 +286,79 @@
     assertCreateSucceeds(in);
   }
 
+  @Test
+  public void cherryPickCommitWithoutChangeId() throws Exception {
+    // This test is a little superfluous, since the current cherry-pick code ignores
+    // the commit message of the to-be-cherry-picked change, using the one in
+    // CherryPickInput instead.
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    input.message = "it goes to foo branch";
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    RevCommit revCommit = createNewCommitWithoutChangeId();
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    CommitInfo commitInfo = revInfo.commit;
+    assertThat(commitInfo.message)
+        .isEqualTo(input.message + "\n\nChange-Id: " + changeInfo.changeId + "\n");
+  }
+
+  @Test
+  public void cherryPickCommitWithChangeId() throws Exception {
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+
+    RevCommit revCommit = createChange().getCommit();
+    List<String> footers = revCommit.getFooterLines("Change-Id");
+    assertThat(footers).hasSize(1);
+    String changeId = footers.get(0);
+
+    input.message = "it goes to foo branch\n\nChange-Id: " + changeId;
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+
+    ChangeInfo changeInfo =
+        gApi.projects().name(project.get()).commit(revCommit.getName()).cherryPick(input).get();
+
+    assertThat(changeInfo.messages).hasSize(1);
+    Iterator<ChangeMessageInfo> messageIterator = changeInfo.messages.iterator();
+    String expectedMessage =
+        String.format("Patch Set 1: Cherry Picked from commit %s.", revCommit.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
+
+    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(input.message + "\n");
+  }
+
+  private RevCommit createNewCommitWithoutChangeId() throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk walk = new RevWalk(repo)) {
+      Ref ref = repo.exactRef("refs/heads/master");
+      RevCommit tip = null;
+      if (ref != null) {
+        tip = walk.parseCommit(ref.getObjectId());
+      }
+      TestRepository<?> testSrcRepo = new TestRepository<>(repo);
+      TestRepository<?>.BranchBuilder builder = testSrcRepo.branch("refs/heads/master");
+      RevCommit revCommit =
+          tip == null
+              ? builder.commit().message("commit 1").add("a.txt", "content").create()
+              : builder.commit().parent(tip).message("commit 1").add("a.txt", "content").create();
+      assertThat(GitUtil.getChangeId(testSrcRepo, revCommit).isPresent()).isFalse();
+      return revCommit;
+    }
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 8d9885c..b4f68fa 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -586,7 +586,7 @@
             public boolean updateChange(ChangeContext ctx) throws OrmException {
               PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
               psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, ImmutableList.<String>of());
-              ctx.bumpLastUpdatedOn(false);
+              ctx.dontBumpLastUpdatedOn();
               return true;
             }
           });
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index ab543e8..14ac3f0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -23,19 +23,28 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.WatchConfig;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.testutil.FakeEmailSender.Message;
+import com.google.inject.Inject;
+import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
@@ -43,6 +52,8 @@
 @NoHttpd
 @Sandboxed
 public class ProjectWatchIT extends AbstractDaemonTest {
+  @Inject private WatchConfig.Accessor watchConfig;
+
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
     Address addr = new Address("Watcher", "watcher@example.com");
@@ -137,6 +148,60 @@
   }
 
   @Test
+  public void noNotificationForPrivateChangesForWatchersInNotifyConfig() throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    sender.clear();
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForChangeThatIsTurnedPrivateForWatchersInNotifyConfig()
+      throws Exception {
+    Address addr = new Address("Watcher", "watcher@example.com");
+    NotifyConfig nc = new NotifyConfig();
+    nc.addEmail(addr);
+    nc.setName("team");
+    nc.setHeader(NotifyConfig.Header.TO);
+    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.putNotifyConfig("team", nc);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    sender.clear();
+
+    r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, "subject", "a", "a2", r.getChangeId())
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void watchProject() throws Exception {
     // watch project
     String watchedProject = createProject("watchedProject").get();
@@ -480,4 +545,99 @@
     // assert email notification
     assertThat(sender.getMessages()).isEmpty();
   }
+
+  @Test
+  public void deleteAllProjectWatches() throws Exception {
+    Map<ProjectWatchKey, Set<NotifyType>> watches = new HashMap<>();
+    watches.put(ProjectWatchKey.create(project, "*"), ImmutableSet.of(NotifyType.ALL));
+    watchConfig.upsertProjectWatches(admin.getId(), watches);
+    assertThat(watchConfig.getProjectWatches(admin.getId())).isNotEmpty();
+
+    watchConfig.deleteAllProjectWatches(admin.getId());
+    assertThat(watchConfig.getProjectWatches(admin.getId())).isEmpty();
+  }
+
+  @Test
+  public void deleteAllProjectWatchesIfWatchConfigIsTheOnlyFileInUserBranch() throws Exception {
+    // Create account that has no files in its refs/users/ branch.
+    Account.Id id = new Account.Id(db.nextAccountId());
+    Account a = new Account(id, TimeUtil.nowTs());
+    db.accounts().insert(Collections.singleton(a));
+
+    // Add a project watch so that a watch.config file in the refs/users/ branch is created.
+    Map<ProjectWatchKey, Set<NotifyType>> watches = new HashMap<>();
+    watches.put(ProjectWatchKey.create(project, "*"), ImmutableSet.of(NotifyType.ALL));
+    watchConfig.upsertProjectWatches(id, watches);
+    assertThat(watchConfig.getProjectWatches(id)).isNotEmpty();
+
+    // Delete all project watches so that the watch.config file in the refs/users/ branch is
+    // deleted.
+    watchConfig.deleteAllProjectWatches(id);
+    assertThat(watchConfig.getProjectWatches(id)).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNoNotificationForPrivateChange() throws Exception {
+    // watch project
+    String watchedProject = createProject("watchedProject").get();
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // push a private change to watched project -> should not trigger email notification
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "private change", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchProjectNotifyOnPrivateChange() throws Exception {
+    String watchedProject = createProject("watchedProject").get();
+
+    // create group that can view all private changes
+    GroupInfo groupThatCanViewPrivateChanges =
+        gApi.groups().create("groupThatCanViewPrivateChanges").get();
+    grant(
+        Permission.VIEW_PRIVATE_CHANGES,
+        new Project.NameKey(watchedProject),
+        "refs/*",
+        false,
+        new AccountGroup.UUID(groupThatCanViewPrivateChanges.id));
+
+    // watch project as user that can't view private changes
+    setApiUser(user);
+    watch(watchedProject, null);
+
+    // watch project as user that can view all private change
+    TestAccount userThatCanViewPrivateChanges =
+        accounts.create("user2", "user2@test.com", "User2", groupThatCanViewPrivateChanges.name);
+    setApiUser(userThatCanViewPrivateChanges);
+    watch(watchedProject, null);
+
+    // push a private change to watched project -> should trigger email notification for
+    // userThatCanViewPrivateChanges, but not for user
+    setApiUser(admin);
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(new Project.NameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), watchedRepo, "TRIGGER", "a", "a1")
+            .to("refs/for/master%private");
+    r.assertOkStatus();
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(userThatCanViewPrivateChanges.emailAddress);
+    assertThat(m.body()).contains("Change subject: TRIGGER\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+  }
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java b/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
index 77a0a5f..1ac42d1 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/IoUtil.java
@@ -26,6 +26,7 @@
 import java.net.URLClassLoader;
 import java.nio.file.Path;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Set;
 
@@ -54,7 +55,7 @@
     }.start();
   }
 
-  public static void loadJARs(Iterable<Path> jars) {
+  public static void loadJARs(Collection<Path> jars) {
     ClassLoader cl = IoUtil.class.getClassLoader();
     if (!(cl instanceof URLClassLoader)) {
       throw noAddURL("Not loaded by URLClassLoader", null);
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
index 47c5224..6222c1b 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/data/Permission.java
@@ -47,6 +47,7 @@
   public static final String SUBMIT = "submit";
   public static final String SUBMIT_AS = "submitAs";
   public static final String VIEW_DRAFTS = "viewDrafts";
+  public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
 
   private static final List<String> NAMES_LC;
   private static final int LABEL_INDEX;
@@ -74,6 +75,7 @@
     NAMES_LC.add(SUBMIT.toLowerCase());
     NAMES_LC.add(SUBMIT_AS.toLowerCase());
     NAMES_LC.add(VIEW_DRAFTS.toLowerCase());
+    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
     NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
     NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index 0f8ec09..7da2873 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -38,9 +38,9 @@
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
@@ -76,7 +76,7 @@
   private final AccountMapping mapping;
   private final Provider<AccountCache> accountCache;
 
-  @AssistedInject
+  @Inject
   ElasticAccountIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 77cae9b..fdc14d7 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -54,9 +55,9 @@
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
@@ -99,7 +100,7 @@
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
 
-  @AssistedInject
+  @Inject
   ElasticChangeIndex(
       @GerritServerConfig Config cfg,
       Provider<ReviewDb> db,
@@ -342,6 +343,16 @@
         cd.setReviewers(ReviewerSet.empty());
       }
 
+      if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
+        cd.setReviewersByEmail(
+            ChangeField.parseReviewerByEmailFieldValues(
+                FluentIterable.from(
+                        source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                    .transform(JsonElement::getAsString)));
+      } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
+        cd.setReviewersByEmail(ReviewerByEmailSet.empty());
+      }
+
       decodeSubmitRecords(
           source,
           ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index 94b39a0..607df83 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -35,9 +35,9 @@
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
@@ -73,7 +73,7 @@
   private final GroupMapping mapping;
   private final Provider<GroupCache> groupCache;
 
-  @AssistedInject
+  @Inject
   ElasticGroupIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index 7375893..178b2bd 100644
--- a/gerrit-extension-api/pom.xml
+++ b/gerrit-extension-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>2.14-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 27fdc18..1d4f0a8 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -85,6 +85,8 @@
 
   void move(MoveInput in) throws RestApiException;
 
+  void setPrivate(boolean value) throws RestApiException;
+
   /**
    * Create a new change that reverts this change.
    *
@@ -307,6 +309,11 @@
     }
 
     @Override
+    public void setPrivate(boolean value) {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ChangeApi revert() {
       throw new NotImplementedException();
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
index af61481..3a33de9 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewerInfo.java
@@ -25,6 +25,13 @@
    */
   @Nullable public Map<String, String> approvals;
 
+  public static ReviewerInfo byEmail(@Nullable String name, String email) {
+    ReviewerInfo info = new ReviewerInfo();
+    info.name = name;
+    info.email = email;
+    return info;
+  }
+
   public ReviewerInfo(Integer id) {
     super(id);
   }
@@ -33,4 +40,6 @@
   public String toString() {
     return username;
   }
+
+  private ReviewerInfo() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
new file mode 100644
index 0000000..85bd952
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface CommitApi {
+
+  ChangeApi cherryPick(CherryPickInput input) throws RestApiException;
+
+  /** A default implementation for source compatibility when adding new methods to the interface. */
+  class NotImplemented implements CommitApi {
+    @Override
+    public ChangeApi cherryPick(CherryPickInput input) {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index cc91a4a..f6f9811 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -31,6 +31,7 @@
   public InheritedBooleanInfo enableSignedPush;
   public InheritedBooleanInfo requireSignedPush;
   public InheritedBooleanInfo rejectImplicitMerges;
+  public InheritedBooleanInfo enableReviewerByEmail;
   public MaxObjectSizeLimitInfo maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index dc2f899..34b298e 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -125,6 +125,14 @@
   TagApi tag(String ref) throws RestApiException;
 
   /**
+   * Lookup a commit by its {@Code ObjectId} string.
+   *
+   * @param commit the {@Code ObjectId} string.
+   * @return API for accessing the commit.
+   */
+  CommitApi commit(String commit) throws RestApiException;
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -218,5 +226,10 @@
     public void deleteTags(DeleteTagsInput in) {
       throw new NotImplementedException();
     }
+
+    @Override
+    public CommitApi commit(String commit) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
index 5e9ca94..2225a99 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
@@ -29,7 +29,7 @@
   public String path;
   public Side side;
   public Integer parent;
-  public Integer line;
+  public Integer line; // value 0 or null indicates a file comment, normal lines start at 1
   public Range range;
   public String inReplyTo;
   public Timestamp updated;
@@ -37,15 +37,15 @@
   public Boolean unresolved;
 
   public static class Range {
-    public int startLine;
-    public int startCharacter;
-    public int endLine;
-    public int endCharacter;
+    public int startLine; // 1-based, inclusive
+    public int startCharacter; // 0-based, inclusive
+    public int endLine; // 1-based, exclusive
+    public int endCharacter; // 0-based, exclusive
 
     public boolean isValid() {
-      return startLine >= 0
+      return startLine > 0
           && startCharacter >= 0
-          && endLine >= 0
+          && endLine > 0
           && endCharacter >= 0
           && startLine <= endLine
           && (startLine != endLine || startCharacter <= endCharacter);
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
index 2fb32d7..f20509b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 public class AccountInfo {
   public Integer _accountId;
@@ -29,4 +30,34 @@
   public AccountInfo(Integer id) {
     this._accountId = id;
   }
+
+  /** To be used ONLY in connection with unregistered reviewers and CCs. */
+  public AccountInfo(String name, String email) {
+    this.name = name;
+    this.email = email;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof AccountInfo) {
+      AccountInfo accountInfo = (AccountInfo) o;
+      return Objects.equals(_accountId, accountInfo._accountId)
+          && Objects.equals(name, accountInfo.name)
+          && Objects.equals(email, accountInfo.email)
+          && Objects.equals(secondaryEmails, accountInfo.secondaryEmails)
+          && Objects.equals(username, accountInfo.username)
+          && Objects.equals(avatars, accountInfo.avatars)
+          && Objects.equals(_moreAccounts, accountInfo._moreAccounts)
+          && Objects.equals(status, accountInfo.status);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        _accountId, name, email, secondaryEmails, username, avatars, _moreAccounts, status);
+  }
+
+  protected AccountInfo() {}
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 3803714..e13962d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -44,6 +44,7 @@
   public Integer insertions;
   public Integer deletions;
   public Integer unresolvedCommentCount;
+  public Boolean isPrivate;
 
   public int _number;
 
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index e79918f..735b84f 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -20,6 +20,7 @@
   public String id;
   public String tag;
   public AccountInfo author;
+  public AccountInfo realAuthor;
   public Timestamp date;
   public String message;
   public Integer _revisionNumber;
diff --git a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java b/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java
index f209e7c..9695933 100644
--- a/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java
+++ b/gerrit-extension-api/src/test/java/com/google/gerrit/extensions/client/RangeTest.java
@@ -63,6 +63,30 @@
   }
 
   @Test
+  public void zeroStartLineResultsInInvalidRange() {
+    Comment.Range range = createRange(0, 2, 19, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void zeroEndLineResultsInInvalidRange() {
+    Comment.Range range = createRange(13, 2, 0, 10);
+    assertThat(range).isInvalid();
+  }
+
+  @Test
+  public void zeroStartCharacterResultsInValidRange() {
+    Comment.Range range = createRange(13, 0, 19, 10);
+    assertThat(range).isValid();
+  }
+
+  @Test
+  public void zeroEndCharacterResultsInValidRange() {
+    Comment.Range range = createRange(13, 31, 19, 0);
+    assertThat(range).isValid();
+  }
+
+  @Test
   public void startLineGreaterThanEndLineResultsInInvalidRange() {
     Comment.Range range = createRange(20, 2, 19, 10);
     assertThat(range).isInvalid();
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index 8e503ee..b0429cb 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.gpg;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
@@ -24,7 +24,7 @@
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index 62d0df7..c3dec61 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
@@ -30,7 +30,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
 
-  @AssistedInject
+  @Inject
   GerritPushCertificateChecker(
       GerritPublicKeyChecker.Factory keyCheckerFactory,
       GitRepositoryManager repoManager,
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 9aa18fe..14a4c6d 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.gpg.server.GpgKey;
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.bouncycastle.openpgp.PGPException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -36,7 +36,7 @@
   private final DeleteGpgKey delete;
   private final GpgKey rsrc;
 
-  @AssistedInject
+  @Inject
   GpgKeyApiImpl(GpgKeys.Get get, DeleteGpgKey delete, @Assisted GpgKey rsrc) {
     this.get = get;
     this.delete = delete;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index 50bf57b..64286c4 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.gpg.server;
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -26,8 +26,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
index 819ad96..13fb368 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -14,12 +14,10 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -36,11 +34,11 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -70,6 +68,7 @@
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
+  private final ExternalIds externalIds;
 
   @Inject
   GpgKeys(
@@ -77,12 +76,14 @@
       Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory) {
+      GerritPublicKeyChecker.Factory checkerFactory,
+      ExternalIds externalIds) {
     this.views = views;
     this.db = db;
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
+    this.externalIds = externalIds;
   }
 
   @Override
@@ -198,16 +199,8 @@
     }
   }
 
-  @VisibleForTesting
-  public static FluentIterable<ExternalId> getGpgExtIds(ReviewDb db, Account.Id accountId)
-      throws OrmException {
-    return FluentIterable.from(
-            ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()))
-        .filter(in -> in.isScheme(SCHEME_GPGKEY));
-  }
-
-  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws OrmException {
-    return getGpgExtIds(db.get(), rsrc.getUser().getAccountId());
+  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException, OrmException {
+    return externalIds.byAccount(db.get(), rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
   }
 
   private static long keyId(byte[] fp) {
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 7b825b1..9c04ced 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 
@@ -47,8 +47,9 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -91,6 +92,7 @@
   private final AddKeySender.Factory addKeyFactory;
   private final AccountCache accountCache;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   @Inject
@@ -103,6 +105,7 @@
       AddKeySender.Factory addKeyFactory,
       AccountCache accountCache,
       Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
     this.db = db;
@@ -112,6 +115,7 @@
     this.addKeyFactory = addKeyFactory;
     this.accountCache = accountCache;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
@@ -122,7 +126,7 @@
     GpgKeys.checkVisible(self, rsrc);
 
     Collection<ExternalId> existingExtIds =
-        GpgKeys.getGpgExtIds(db.get(), rsrc.getUser().getAccountId()).toList();
+        externalIds.byAccount(db.get(), rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
     try (PublicKeyStore store = storeProvider.get()) {
       Set<Fingerprint> toRemove = readKeysToRemove(input, existingExtIds);
       List<PGPPublicKeyRing> newKeys = readKeysToAdd(input, toRemove);
diff --git a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 862930f..d82f95b 100644
--- a/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/gerrit-gpg/src/test/java/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -39,8 +39,8 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index 0de8b68..3cac62c 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -138,6 +138,8 @@
 
   public final native boolean reviewed() /*-{ return this.reviewed ? true : false; }-*/;
 
+  public final native boolean isPrivate() /*-{ return this.is_private ? true : false; }-*/;
+
   public final native NativeMap<LabelInfo> allLabels() /*-{ return this.labels; }-*/;
 
   public final native LabelInfo label(String n) /*-{ return this.labels[n]; }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
index 5c0508e..09182f9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/SearchSuggestOracle.java
@@ -131,6 +131,7 @@
     suggestions.add("is:open");
     suggestions.add("is:pending");
     suggestions.add("is:draft");
+    suggestions.add("is:private");
     suggestions.add("is:closed");
     suggestions.add("is:merged");
     suggestions.add("is:abandoned");
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
index 40116af..cb529f4 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/UserPopupPanel.java
@@ -52,8 +52,9 @@
       userEmail.setText(account.email());
     }
     if (showSettingsLink) {
-      if (Gerrit.info().auth().switchAccountUrl() != null) {
-        switchAccount.setHref(Gerrit.info().auth().switchAccountUrl());
+      String switchAccountUrl = Gerrit.info().auth().switchAccountUrl();
+      if (switchAccountUrl != null) {
+        switchAccount.setHref(switchAccountUrl.replace("${path}", "/"));
       } else if (Gerrit.info().auth().isDev() || Gerrit.info().auth().isOpenId()) {
         switchAccount.setHref(Gerrit.selfRedirect("/login"));
       } else {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
index 465bcfc..200a0c5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
@@ -148,7 +148,8 @@
 	removeReviewer, \
 	submit, \
 	submitAs, \
-	viewDrafts
+	viewDrafts, \
+	viewPrivateChanges
 
 abandon = Abandon
 addPatchSet = Add Patch Set
@@ -174,6 +175,7 @@
 submit = Submit
 submitAs = Submit (On Behalf Of)
 viewDrafts = View Drafts
+viewPrivateChanges = View Private Changes
 
 refErrorEmpty = Reference must be supplied
 refErrorBeginSlash = Reference must not start with '/'
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
index 3645fb9..7830696 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectInfoScreen.java
@@ -86,6 +86,7 @@
   private ListBox enableSignedPush;
   private ListBox requireSignedPush;
   private ListBox rejectImplicitMerges;
+  private ListBox enableReviewerByEmail;
   private NpTextBox maxObjectSizeLimit;
   private Label effectiveMaxObjectSizeLimit;
   private Map<String, Map<String, HasEnabled>> pluginConfigWidgets;
@@ -191,6 +192,7 @@
     requireChangeID.setEnabled(isOwner);
     rejectImplicitMerges.setEnabled(isOwner);
     maxObjectSizeLimit.setEnabled(isOwner);
+    enableReviewerByEmail.setEnabled(isOwner);
 
     if (pluginConfigWidgets != null) {
       for (Map<String, HasEnabled> widgetMap : pluginConfigWidgets.values()) {
@@ -264,6 +266,10 @@
     saveEnabler.listenTo(rejectImplicitMerges);
     grid.addHtml(AdminConstants.I.rejectImplicitMerges(), rejectImplicitMerges);
 
+    enableReviewerByEmail = newInheritedBooleanBox();
+    saveEnabler.listenTo(enableReviewerByEmail);
+    grid.addHtml(AdminConstants.I.rejectImplicitMerges(), enableReviewerByEmail);
+
     maxObjectSizeLimit = new NpTextBox();
     saveEnabler.listenTo(maxObjectSizeLimit);
     effectiveMaxObjectSizeLimit = new Label();
@@ -395,6 +401,7 @@
       setBool(requireSignedPush, result.requireSignedPush());
     }
     setBool(rejectImplicitMerges, result.rejectImplicitMerges());
+    setBool(enableReviewerByEmail, result.enableReviewerByEmail());
     setSubmitType(result.submitType());
     setState(result.state());
     maxObjectSizeLimit.setText(result.maxObjectSizeLimit().configuredValue());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
index ada28af..b22b79f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.java
@@ -48,6 +48,7 @@
     "revert",
     "submit",
     "topic",
+    "private",
     "/",
   };
 
@@ -65,6 +66,9 @@
 
   @UiField Button deleteChange;
 
+  @UiField Button markPrivate;
+  @UiField Button unmarkPrivate;
+
   @UiField Button restore;
   private RestoreAction restoreAction;
 
@@ -122,6 +126,11 @@
       a2b(actions, "restore", restore);
       a2b(actions, "revert", revert);
       a2b(actions, "followup", followUp);
+      if (info.isPrivate()) {
+        a2b(actions, "private", unmarkPrivate);
+      } else {
+        a2b(actions, "private", markPrivate);
+      }
       for (String id : filterNonCore(actions)) {
         add(new ActionButton(info, actions.get(id)));
       }
@@ -192,6 +201,16 @@
     }
   }
 
+  @UiHandler("markPrivate")
+  void onMarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
+    ChangeActions.markPrivate(changeId, markPrivate);
+  }
+
+  @UiHandler("unmarkPrivate")
+  void onUnmarkPrivate(@SuppressWarnings("unused") ClickEvent e) {
+    ChangeActions.unmarkPrivate(changeId, unmarkPrivate);
+  }
+
   @UiHandler("restore")
   void onRestore(@SuppressWarnings("unused") ClickEvent e) {
     if (restoreAction == null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
index d0e5c3e..60efc8c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Actions.ui.xml
@@ -81,6 +81,12 @@
     <g:Button ui:field='followUp' styleName='' visible='false'>
       <div><ui:msg>Follow-Up</ui:msg></div>
     </g:Button>
+    <g:Button ui:field='markPrivate' styleName='' visible='false'>
+      <div><ui:msg>Mark Private</ui:msg></div>
+    </g:Button>
+    <g:Button ui:field='unmarkPrivate' styleName='' visible='false'>
+      <div><ui:msg>Unmark Private</ui:msg></div>
+    </g:Button>
 
     <g:Button ui:field='submit' styleName='{style.submit}' visible='false'/>
   </g:FlowPanel>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
index 1be60cc..b8fcab7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeActions.java
@@ -37,6 +37,14 @@
     ChangeApi.deleteChange(id.get(), mine(draftButtons));
   }
 
+  static void markPrivate(Change.Id id, Button... draftButtons) {
+    ChangeApi.markPrivate(id.get(), cs(id, draftButtons));
+  }
+
+  static void unmarkPrivate(Change.Id id, Button... draftButtons) {
+    ChangeApi.unmarkPrivate(id.get(), cs(id, draftButtons));
+  }
+
   public static GerritCallback<JavaScriptObject> cs(
       final Change.Id id, final Button... draftButtons) {
     setEnabled(false, draftButtons);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index 3cf6f4b..bb94ee3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -198,6 +198,7 @@
   @UiField InlineLabel uploaderName;
 
   @UiField Element statusText;
+  @UiField Element privateText;
   @UiField Image projectSettings;
   @UiField AnchorElement projectSettingsLink;
   @UiField InlineHyperlink projectDashboard;
@@ -1370,6 +1371,10 @@
       statusText.setInnerText(Util.toLongString(s));
     }
 
+    if (info.isPrivate()) {
+      privateText.setInnerText(Util.C.isPrivate());
+    }
+
     if (Gerrit.isSignedIn()) {
       replyAction =
           new ReplyAction(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
index 152b157..e2297cb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.ui.xml
@@ -99,6 +99,9 @@
     .statusText {
       font-weight: bold;
     }
+    .privateText {
+      font-weight: bold;
+    }
 
     div.popdown {
       display: inline-block;
@@ -376,7 +379,8 @@
           <span class='{style.changeId}'>
             <ui:msg>Change <g:Anchor ui:field='permalink' title='Reload the change (Shortcut: R)'>
               <ui:attribute name='title'/>
-            </g:Anchor> - <span ui:field='statusText' class='{style.statusText}'/></ui:msg>
+            </g:Anchor> - <span ui:field='statusText' class='{style.statusText}'/>
+              <span ui:field='privateText' class='{style.privateText}'/></ui:msg>
           </span>
           <g:SimplePanel ui:field='headerExtension' styleName='{style.headerExtension}'/>
         </div>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
index f8a9ba1..f985f31 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
@@ -121,6 +121,14 @@
     change(id).view("assignee").put(input, cb);
   }
 
+  public static void markPrivate(int id, AsyncCallback<JavaScriptObject> cb) {
+    change(id).view("private").put(cb);
+  }
+
+  public static void unmarkPrivate(int id, AsyncCallback<JavaScriptObject> cb) {
+    change(id).view("private").delete(cb);
+  }
+
   public static RestApi comments(int id) {
     return call(id, "comments");
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
index ae64ac0..4543217 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
@@ -33,6 +33,8 @@
 
   String notCurrent();
 
+  String isPrivate();
+
   String changeEdit();
 
   String myDashboardTitle();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index 01921de..3545a2f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -7,6 +7,7 @@
 mergeConflict = Merge Conflict
 notCurrent = Not Current
 changeEdit = Change Edit
+isPrivate = (Private)
 
 myDashboardTitle = My Reviews
 unknownDashboardTitle = Code Review Dashboard
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
index ab41ad6..d80bd56 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java
@@ -237,9 +237,17 @@
 
     Change.Status status = c.status();
     if (status != Change.Status.NEW) {
-      table.setText(row, C_STATUS, Util.toLongString(status));
+      table.setText(
+          row,
+          C_STATUS,
+          Util.toLongString(status) + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
     } else if (!c.mergeable()) {
-      table.setText(row, C_STATUS, Util.C.changeTableNotMergeable());
+      table.setText(
+          row,
+          C_STATUS,
+          Util.C.changeTableNotMergeable() + (c.isPrivate() ? (" " + Util.C.isPrivate()) : ""));
+    } else if (c.isPrivate()) {
+      table.setText(row, C_STATUS, Util.C.isPrivate());
     }
 
     if (c.owner() != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
index 953bc87..12f5a02 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ChunkManager.java
@@ -79,12 +79,9 @@
         cm.addLineClass(line, where, className);
       }
       undo.add(
-          new Runnable() {
-            @Override
-            public void run() {
-              for (int line = start; line < end; line++) {
-                cm.removeLineClass(line, where, className);
-              }
+          () -> {
+            for (int line = start; line < end; line++) {
+              cm.removeLineClass(line, where, className);
             }
           });
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
index 587dacc..6bb69a3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
@@ -203,32 +203,26 @@
 
   abstract String getTokenSuffixForActiveLine(CodeMirror cm);
 
-  Runnable signInCallback(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        String token = host.getToken();
-        if (cm.extras().hasActiveLine()) {
-          token += "@" + getTokenSuffixForActiveLine(cm);
-        }
-        Gerrit.doSignIn(token);
+  Runnable signInCallback(CodeMirror cm) {
+    return () -> {
+      String token = host.getToken();
+      if (cm.extras().hasActiveLine()) {
+        token += "@" + getTokenSuffixForActiveLine(cm);
       }
+      Gerrit.doSignIn(token);
     };
   }
 
   abstract void newDraft(CodeMirror cm);
 
-  Runnable newDraftCallback(final CodeMirror cm) {
+  Runnable newDraftCallback(CodeMirror cm) {
     if (!Gerrit.isSignedIn()) {
       return signInCallback(cm);
     }
 
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.extras().hasActiveLine()) {
-          newDraft(cm);
-        }
+    return () -> {
+      if (cm.extras().hasActiveLine()) {
+        newDraft(cm);
       }
     };
   }
@@ -268,51 +262,48 @@
   abstract SortedMap<Integer, CommentGroup> getMapForNav(DisplaySide side);
 
   Runnable commentNav(final CodeMirror src, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // Every comment appears in both side maps as a linked pair.
-        // It is only necessary to search one side to find a comment
-        // on either side of the editor pair.
-        SortedMap<Integer, CommentGroup> map = getMapForNav(src.side());
-        int line =
-            src.extras().hasActiveLine() ? src.getLineNumber(src.extras().activeLine()) + 1 : 0;
+    return () -> {
+      // Every comment appears in both side maps as a linked pair.
+      // It is only necessary to search one side to find a comment
+      // on either side of the editor pair.
+      SortedMap<Integer, CommentGroup> map = getMapForNav(src.side());
+      int line =
+          src.extras().hasActiveLine() ? src.getLineNumber(src.extras().activeLine()) + 1 : 0;
 
-        CommentGroup g;
-        if (dir == Direction.NEXT) {
-          map = map.tailMap(line + 1);
+      CommentGroup g;
+      if (dir == Direction.NEXT) {
+        map = map.tailMap(line + 1);
+        if (map.isEmpty()) {
+          return;
+        }
+        g = map.get(map.firstKey());
+        while (g.getBoxCount() == 0) {
+          map = map.tailMap(map.firstKey() + 1);
           if (map.isEmpty()) {
             return;
           }
           g = map.get(map.firstKey());
-          while (g.getBoxCount() == 0) {
-            map = map.tailMap(map.firstKey() + 1);
-            if (map.isEmpty()) {
-              return;
-            }
-            g = map.get(map.firstKey());
-          }
-        } else {
-          map = map.headMap(line);
+        }
+      } else {
+        map = map.headMap(line);
+        if (map.isEmpty()) {
+          return;
+        }
+        g = map.get(map.lastKey());
+        while (g.getBoxCount() == 0) {
+          map = map.headMap(map.lastKey());
           if (map.isEmpty()) {
             return;
           }
           g = map.get(map.lastKey());
-          while (g.getBoxCount() == 0) {
-            map = map.headMap(map.lastKey());
-            if (map.isEmpty()) {
-              return;
-            }
-            g = map.get(map.lastKey());
-          }
         }
-
-        CodeMirror cm = g.getCm();
-        double y = cm.heightAtLine(g.getLine() - 1, "local");
-        cm.setCursor(Pos.create(g.getLine() - 1));
-        cm.scrollToY(y - 0.5 * cm.scrollbarV().getClientHeight());
-        cm.focus();
       }
+
+      CodeMirror cm = g.getCm();
+      double y = cm.heightAtLine(g.getLine() - 1, "local");
+      cm.setCursor(Pos.create(g.getLine() - 1));
+      cm.scrollToY(y - 0.5 * cm.scrollbarV().getClientHeight());
+      cm.focus();
     };
   }
 
@@ -425,26 +416,20 @@
 
   abstract CommentGroup getCommentGroupOnActiveLine(CodeMirror cm);
 
-  Runnable toggleOpenBox(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CommentGroup group = getCommentGroupOnActiveLine(cm);
-        if (group != null) {
-          group.openCloseLast();
-        }
+  Runnable toggleOpenBox(CodeMirror cm) {
+    return () -> {
+      CommentGroup group = getCommentGroupOnActiveLine(cm);
+      if (group != null) {
+        group.openCloseLast();
       }
     };
   }
 
-  Runnable openCloseAll(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CommentGroup group = getCommentGroupOnActiveLine(cm);
-        if (group != null) {
-          group.openCloseAll();
-        }
+  Runnable openCloseAll(CodeMirror cm) {
+    return () -> {
+      CommentGroup group = getCommentGroupOnActiveLine(cm);
+      if (group != null) {
+        group.openCloseAll();
       }
     };
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
index 60a75eb..4ff7254 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
@@ -336,7 +336,7 @@
     handlers.clear();
   }
 
-  void registerCmEvents(final CodeMirror cm) {
+  void registerCmEvents(CodeMirror cm) {
     cm.on("cursorActivity", updateActiveLine(cm));
     cm.on("focus", updateActiveLine(cm));
     KeyMap keyMap =
@@ -356,169 +356,108 @@
             .on("Shift-O", getCommentManager().openCloseAll(cm))
             .on(
                 "I",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    switch (getIntraLineStatus()) {
-                      case OFF:
-                      case OK:
-                        toggleShowIntraline();
-                        break;
-                      case FAILURE:
-                      case TIMEOUT:
-                      default:
-                        break;
-                    }
+                () -> {
+                  switch (getIntraLineStatus()) {
+                    case OFF:
+                    case OK:
+                      toggleShowIntraline();
+                      break;
+                    case FAILURE:
+                    case TIMEOUT:
+                    default:
+                      break;
                   }
                 })
-            .on(
-                "','",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    prefsAction.show();
-                  }
-                })
+            .on("','", prefsAction::show)
             .on(
                 "Shift-/",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    new ShowHelpCommand().onKeyPress(null);
-                  }
+                () -> {
+                  new ShowHelpCommand().onKeyPress(null);
                 })
             .on(
                 "Space",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.vim().handleKey("<C-d>");
-                  }
+                () -> {
+                  cm.vim().handleKey("<C-d>");
                 })
             .on(
                 "Shift-Space",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.vim().handleKey("<C-u>");
-                  }
+                () -> {
+                  cm.vim().handleKey("<C-u>");
                 })
             .on(
                 "Ctrl-F",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("find");
-                  }
+                () -> {
+                  cm.execCommand("find");
                 })
             .on(
                 "Ctrl-G",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("findNext");
-                  }
+                () -> {
+                  cm.execCommand("findNext");
                 })
             .on("Enter", maybeNextCmSearch(cm))
             .on(
                 "Shift-Ctrl-G",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("findPrev");
-                  }
+                () -> {
+                  cm.execCommand("findPrev");
                 })
             .on(
                 "Shift-Enter",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("findPrev");
-                  }
+                () -> {
+                  cm.execCommand("findPrev");
                 })
             .on(
                 "Esc",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.setCursor(cm.getCursor());
-                    cm.execCommand("clearSearch");
-                    cm.vim().handleEx("nohlsearch");
-                  }
+                () -> {
+                  cm.setCursor(cm.getCursor());
+                  cm.execCommand("clearSearch");
+                  cm.vim().handleEx("nohlsearch");
                 })
             .on(
                 "Ctrl-A",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    cm.execCommand("selectAll");
-                  }
+                () -> {
+                  cm.execCommand("selectAll");
                 })
             .on(
                 "G O",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    Gerrit.display(PageLinks.toChangeQuery("status:open"));
-                  }
+                () -> {
+                  Gerrit.display(PageLinks.toChangeQuery("status:open"));
                 })
             .on(
                 "G M",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    Gerrit.display(PageLinks.toChangeQuery("status:merged"));
-                  }
+                () -> {
+                  Gerrit.display(PageLinks.toChangeQuery("status:merged"));
                 })
             .on(
                 "G A",
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
-                  }
+                () -> {
+                  Gerrit.display(PageLinks.toChangeQuery("status:abandoned"));
                 });
     if (Gerrit.isSignedIn()) {
       keyMap
           .on(
               "G I",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.MINE);
-                }
+              () -> {
+                Gerrit.display(PageLinks.MINE);
               })
           .on(
               "G D",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
-                }
+              () -> {
+                Gerrit.display(PageLinks.toChangeQuery("owner:self is:draft"));
               })
           .on(
               "G C",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("has:draft"));
-                }
+              () -> {
+                Gerrit.display(PageLinks.toChangeQuery("has:draft"));
               })
           .on(
               "G W",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("is:watched status:open"));
-                }
+              () -> {
+                Gerrit.display(PageLinks.toChangeQuery("is:watched status:open"));
               })
           .on(
               "G S",
-              new Runnable() {
-                @Override
-                public void run() {
-                  Gerrit.display(PageLinks.toChangeQuery("is:starred"));
-                }
+              () -> {
+                Gerrit.display(PageLinks.toChangeQuery("is:starred"));
               });
     }
 
@@ -700,13 +639,10 @@
 
   void setContext(final int context) {
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            skipManager.removeAll();
-            skipManager.render(context, diff);
-            updateRenderEntireFile();
-          }
+        () -> {
+          skipManager.removeAll();
+          skipManager.render(context, diff);
+          updateRenderEntireFile();
         });
   }
 
@@ -753,21 +689,18 @@
     return line - offset;
   }
 
-  private Runnable openEditScreen(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        LineHandle handle = cm.extras().activeLine();
-        int line = cm.getLineNumber(handle) + 1;
-        if (Patch.COMMIT_MSG.equals(path)) {
-          line = adjustCommitMessageLine(line);
-        }
-        String token = Dispatcher.toEditScreen(revision, path, line);
-        if (!Gerrit.isSignedIn()) {
-          Gerrit.doSignIn(token);
-        } else {
-          Gerrit.display(token);
-        }
+  private Runnable openEditScreen(CodeMirror cm) {
+    return () -> {
+      LineHandle handle = cm.extras().activeLine();
+      int line = cm.getLineNumber(handle) + 1;
+      if (Patch.COMMIT_MSG.equals(path)) {
+        line = adjustCommitMessageLine(line);
+      }
+      String token = Dispatcher.toEditScreen(revision, path, line);
+      if (!Gerrit.isSignedIn()) {
+        Gerrit.doSignIn(token);
+      } else {
+        Gerrit.display(token);
       }
     };
   }
@@ -832,63 +765,51 @@
 
   abstract void operation(Runnable apply);
 
-  private Runnable upToChange(final boolean openReplyBox) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        CallbackGroup group = new CallbackGroup();
-        getCommentManager().saveAllDrafts(group);
-        group.done();
-        group.addListener(
-            new GerritCallback<Void>() {
-              @Override
-              public void onSuccess(Void result) {
-                String rev = String.valueOf(revision.get());
-                Gerrit.display(
-                    PageLinks.toChange(changeId, base.asString(), rev),
-                    new ChangeScreen(changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
-              }
-            });
+  private Runnable upToChange(boolean openReplyBox) {
+    return () -> {
+      CallbackGroup group = new CallbackGroup();
+      getCommentManager().saveAllDrafts(group);
+      group.done();
+      group.addListener(
+          new GerritCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+              String rev = String.valueOf(revision.get());
+              Gerrit.display(
+                  PageLinks.toChange(changeId, base.asString(), rev),
+                  new ChangeScreen(changeId, base, rev, openReplyBox, FileTable.Mode.REVIEW));
+            }
+          });
+    };
+  }
+
+  private Runnable maybePrevVimSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.vim().hasSearchHighlight()) {
+        cm.vim().handleKey("N");
+      } else {
+        getCommentManager().commentNav(cm, Direction.NEXT).run();
       }
     };
   }
 
-  private Runnable maybePrevVimSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.vim().hasSearchHighlight()) {
-          cm.vim().handleKey("N");
-        } else {
-          getCommentManager().commentNav(cm, Direction.NEXT).run();
-        }
+  private Runnable maybeNextVimSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.vim().hasSearchHighlight()) {
+        cm.vim().handleKey("n");
+      } else {
+        getChunkManager().diffChunkNav(cm, Direction.NEXT).run();
       }
     };
   }
 
-  private Runnable maybeNextVimSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.vim().hasSearchHighlight()) {
-          cm.vim().handleKey("n");
-        } else {
-          getChunkManager().diffChunkNav(cm, Direction.NEXT).run();
-        }
-      }
-    };
-  }
-
-  Runnable maybeNextCmSearch(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cm.hasSearchHighlight()) {
-          cm.execCommand("findNext");
-        } else {
-          cm.execCommand("clearSearch");
-          getCommentManager().toggleOpenBox(cm).run();
-        }
+  Runnable maybeNextCmSearch(CodeMirror cm) {
+    return () -> {
+      if (cm.hasSearchHighlight()) {
+        cm.execCommand("findNext");
+      } else {
+        cm.execCommand("clearSearch");
+        getCommentManager().toggleOpenBox(cm).run();
       }
     };
   }
@@ -973,7 +894,7 @@
   }
 
   void reloadDiffInfo() {
-    final int id = ++reloadVersionId;
+    int id = ++reloadVersionId;
     DiffApi.diff(revision, path)
         .base(base.asPatchSetId())
         .wholeFile()
@@ -986,16 +907,13 @@
                 if (id == reloadVersionId && isAttached()) {
                   diff = diffInfo;
                   operation(
-                      new Runnable() {
-                        @Override
-                        public void run() {
-                          skipManager.removeAll();
-                          getChunkManager().reset();
-                          getDiffTable().scrollbar.removeDiffAnnotations();
-                          setShowIntraline(prefs.intralineDifference());
-                          render(diff);
-                          skipManager.render(prefs.context(), diff);
-                        }
+                      () -> {
+                        skipManager.removeAll();
+                        getChunkManager().reset();
+                        getDiffTable().scrollbar.removeDiffAnnotations();
+                        setShowIntraline(prefs.intralineDifference());
+                        render(diff);
+                        skipManager.render(prefs.context(), diff);
                       });
                 }
               }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
index a2ffb03f..4fd9906 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Header.java
@@ -318,47 +318,32 @@
   }
 
   Runnable toggleReviewed() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        reviewed.setValue(!reviewed.getValue(), true);
-      }
+    return () -> {
+      reviewed.setValue(!reviewed.getValue(), true);
     };
   }
 
   Runnable navigate(Direction dir) {
     switch (dir) {
       case PREV:
-        return new Runnable() {
-          @Override
-          public void run() {
-            (hasPrev ? prev : up).go();
-          }
+        return () -> {
+          (hasPrev ? prev : up).go();
         };
       case NEXT:
-        return new Runnable() {
-          @Override
-          public void run() {
-            (hasNext ? next : up).go();
-          }
+        return () -> {
+          (hasNext ? next : up).go();
         };
       default:
-        return new Runnable() {
-          @Override
-          public void run() {}
-        };
+        return () -> {};
     }
   }
 
   Runnable reviewedAndNext() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (Gerrit.isSignedIn()) {
-          reviewed.setValue(true, true);
-        }
-        navigate(Direction.NEXT).run();
+    return () -> {
+      if (Gerrit.isSignedIn()) {
+        reviewed.setValue(true, true);
       }
+      navigate(Direction.NEXT).run();
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
index 4d781ea..84a7352 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PreferencesBox.java
@@ -322,13 +322,10 @@
       prefs.tabSize(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
         view.operation(
-            new Runnable() {
-              @Override
-              public void run() {
-                int v = prefs.tabSize();
-                for (CodeMirror cm : view.getCms()) {
-                  cm.setOption("tabSize", v);
-                }
+            () -> {
+              int size = prefs.tabSize();
+              for (CodeMirror cm : view.getCms()) {
+                cm.setOption("tabSize", size);
               }
             });
       }
@@ -342,11 +339,8 @@
       prefs.lineLength(Math.max(1, Integer.parseInt(v)));
       if (view != null) {
         view.operation(
-            new Runnable() {
-              @Override
-              public void run() {
-                view.setLineLength(prefs.lineLength());
-              }
+            () -> {
+              view.setLineLength(prefs.lineLength());
             });
       }
     }
@@ -461,12 +455,9 @@
                     && Objects.equals(mode, getSelectedMode())
                     && view.isAttached()) {
                   view.operation(
-                      new Runnable() {
-                        @Override
-                        public void run() {
-                          view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
-                          view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
-                        }
+                      () -> {
+                        view.getCmFromSide(DisplaySide.A).setOption("mode", mode);
+                        view.getCmFromSide(DisplaySide.B).setOption("mode", mode);
                       });
                 }
               }
@@ -483,13 +474,10 @@
     prefs.showWhitespaceErrors(e.getValue());
     if (view != null) {
       view.operation(
-          new Runnable() {
-            @Override
-            public void run() {
-              boolean s = prefs.showWhitespaceErrors();
-              for (CodeMirror cm : view.getCms()) {
-                cm.setOption("showTrailingSpace", s);
-              }
+          () -> {
+            boolean s = prefs.showWhitespaceErrors();
+            for (CodeMirror cm : view.getCms()) {
+              cm.setOption("showTrailingSpace", s);
             }
           });
     }
@@ -546,15 +534,12 @@
             @Override
             public void onSuccess(Void result) {
               view.operation(
-                  new Runnable() {
-                    @Override
-                    public void run() {
-                      if (getSelectedTheme() == newTheme && isAttached()) {
-                        String t = newTheme.name().toLowerCase();
-                        view.getCmFromSide(DisplaySide.A).setOption("theme", t);
-                        view.getCmFromSide(DisplaySide.B).setOption("theme", t);
-                        view.setThemeStyles(newTheme.isDark());
-                      }
+                  () -> {
+                    if (getSelectedTheme() == newTheme && isAttached()) {
+                      String t = newTheme.name().toLowerCase();
+                      view.getCmFromSide(DisplaySide.A).setOption("theme", t);
+                      view.getCmFromSide(DisplaySide.B).setOption("theme", t);
+                      view.setThemeStyles(newTheme.isDark());
                     }
                   });
             }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
index 6cb9b6a..ecdac46 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/ScrollbarAnnotation.java
@@ -64,12 +64,9 @@
     refresh =
         cmB.on(
             "refresh",
-            new Runnable() {
-              @Override
-              public void run() {
-                if (updateScale()) {
-                  updatePosition();
-                }
+            () -> {
+              if (updateScale()) {
+                updatePosition();
               }
             });
     updateScale();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
index 1560597..547b0c6 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySide.java
@@ -102,14 +102,11 @@
     super.onShowView();
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            resizeCodeMirror();
-            chunkManager.adjustPadding();
-            cmA.refresh();
-            cmB.refresh();
-          }
+        () -> {
+          resizeCodeMirror();
+          chunkManager.adjustPadding();
+          cmA.refresh();
+          cmB.refresh();
         });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
@@ -209,18 +206,15 @@
     chunkManager = new SideBySideChunkManager(this, cmA, cmB, diffTable.scrollbar);
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            // Estimate initial CodeMirror height, fixed up in onShowView.
-            int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
-            cmA.setHeight(height);
-            cmB.setHeight(height);
+        () -> {
+          // Estimate initial CodeMirror height, fixed up in onShowView.
+          int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
+          cmA.setHeight(height);
+          cmB.setHeight(height);
 
-            render(diff);
-            commentManager.render(comments, prefs.expandAllComments());
-            skipManager.render(prefs.context(), diff);
-          }
+          render(diff);
+          commentManager.render(comments, prefs.expandAllComments());
+          skipManager.render(prefs.context(), diff);
         });
 
     registerCmEvents(cmA);
@@ -321,64 +315,50 @@
   @Override
   Runnable updateActiveLine(final CodeMirror cm) {
     final CodeMirror other = otherCm(cm);
-    return new Runnable() {
-      @Override
-      public void run() {
-        // The rendering of active lines has to be deferred. Reflow
-        // caused by adding and removing styles chokes Firefox when arrow
-        // key (or j/k) is held down. Performance on Chrome is fine
-        // without the deferral.
-        //
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    operation(
-                        new Runnable() {
-                          @Override
-                          public void run() {
-                            LineHandle handle =
-                                cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                            if (!cm.extras().activeLine(handle)) {
-                              return;
-                            }
+    return () -> {
+      // The rendering of active lines has to be deferred. Reflow
+      // caused by adding and removing styles chokes Firefox when arrow
+      // key (or j/k) is held down. Performance on Chrome is fine
+      // without the deferral.
+      //
+      Scheduler.get()
+          .scheduleDeferred(
+              new ScheduledCommand() {
+                @Override
+                public void execute() {
+                  operation(
+                      () -> {
+                        LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                        if (!cm.extras().activeLine(handle)) {
+                          return;
+                        }
 
-                            LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
-                            if (info.isAligned()) {
-                              other.extras().activeLine(other.getLineHandle(info.getLine()));
-                            } else {
-                              other.extras().clearActiveLine();
-                            }
-                          }
-                        });
-                  }
-                });
-      }
+                        LineOnOtherInfo info = lineOnOther(cm.side(), cm.getLineNumber(handle));
+                        if (info.isAligned()) {
+                          other.extras().activeLine(other.getLineHandle(info.getLine()));
+                        } else {
+                          other.extras().clearActiveLine();
+                        }
+                      });
+                }
+              });
     };
   }
 
   private Runnable moveCursorToSide(final CodeMirror cmSrc, DisplaySide sideDst) {
     final CodeMirror cmDst = getCmFromSide(sideDst);
     if (cmDst == cmSrc) {
-      return new Runnable() {
-        @Override
-        public void run() {}
-      };
+      return () -> {};
     }
 
     final DisplaySide sideSrc = cmSrc.side();
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (cmSrc.extras().hasActiveLine()) {
-          cmDst.setCursor(
-              Pos.create(
-                  lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine()))
-                      .getLine()));
-        }
-        cmDst.focus();
+    return () -> {
+      if (cmSrc.extras().hasActiveLine()) {
+        cmDst.setCursor(
+            Pos.create(
+                lineOnOther(sideSrc, cmSrc.getLineNumber(cmSrc.extras().activeLine())).getLine()));
       }
+      cmDst.focus();
     };
   }
 
@@ -389,19 +369,10 @@
   }
 
   @Override
-  void operation(final Runnable apply) {
+  void operation(Runnable apply) {
     cmA.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmB.operation(
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    apply.run();
-                  }
-                });
-          }
+        () -> {
+          cmB.operation(apply::run);
         });
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
index a78e59e..507bb5b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideChunkManager.java
@@ -246,15 +246,12 @@
 
   @Override
   Runnable diffChunkNav(final CodeMirror cm, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
-        int res =
-            Collections.binarySearch(
-                chunks, new DiffChunkInfo(cm.side(), line, 0, false), getDiffChunkComparator());
-        diffChunkNavHelper(chunks, host, res, dir);
-      }
+    return () -> {
+      int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
+      int res =
+          Collections.binarySearch(
+              chunks, new DiffChunkInfo(cm.side(), line, 0, false), getDiffChunkComparator());
+      diffChunkNavHelper(chunks, host, res, dir);
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
index 6fcd6c8..c728f6f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentGroup.java
@@ -88,29 +88,26 @@
   void handleRedraw() {
     getLineWidget()
         .onRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                if (canComputeHeight() && peers.peek().canComputeHeight()) {
-                  if (getResizeTimer() != null) {
-                    getResizeTimer().cancel();
-                    setResizeTimer(null);
-                  }
-                  adjustPadding(SideBySideCommentGroup.this, peers.peek());
-                } else if (getResizeTimer() == null) {
-                  setResizeTimer(
-                      new Timer() {
-                        @Override
-                        public void run() {
-                          if (canComputeHeight() && peers.peek().canComputeHeight()) {
-                            cancel();
-                            setResizeTimer(null);
-                            adjustPadding(SideBySideCommentGroup.this, peers.peek());
-                          }
-                        }
-                      });
-                  getResizeTimer().scheduleRepeating(5);
+            () -> {
+              if (canComputeHeight() && peers.peek().canComputeHeight()) {
+                if (getResizeTimer() != null) {
+                  getResizeTimer().cancel();
+                  setResizeTimer(null);
                 }
+                adjustPadding(SideBySideCommentGroup.this, peers.peek());
+              } else if (getResizeTimer() == null) {
+                setResizeTimer(
+                    new Timer() {
+                      @Override
+                      public void run() {
+                        if (canComputeHeight() && peers.peek().canComputeHeight()) {
+                          cancel();
+                          setResizeTimer(null);
+                          adjustPadding(SideBySideCommentGroup.this, peers.peek());
+                        }
+                      }
+                    });
+                getResizeTimer().scheduleRepeating(5);
               }
             });
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
index 7465c81..bfbd235 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideTable.java
@@ -75,11 +75,8 @@
   }
 
   Runnable toggleA() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        setVisibleA(!isVisibleA());
-      }
+    return () -> {
+      setVisibleA(!isVisibleA());
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
index 03cfd60..eafb10f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SkipBar.java
@@ -91,12 +91,9 @@
       }
       if (isNew) {
         lineWidget.onFirstRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                int w = cm.getGutterElement().getOffsetWidth();
-                getElement().getStyle().setPaddingLeft(w, Unit.PX);
-              }
+            () -> {
+              int w = cm.getGutterElement().getOffsetWidth();
+              getElement().getStyle().setPaddingLeft(w, Unit.PX);
             });
       }
     }
@@ -110,14 +107,7 @@
                 .set("inclusiveLeft", true)
                 .set("inclusiveRight", true));
 
-    textMarker.on(
-        "beforeCursorEnter",
-        new Runnable() {
-          @Override
-          public void run() {
-            expandAll();
-          }
-        });
+    textMarker.on("beforeCursorEnter", this::expandAll);
 
     int skipped = end - start + 1;
     if (skipped <= UP_DOWN_THRESHOLD) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
index 0f0ba41..93874b0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/Unified.java
@@ -30,7 +30,6 @@
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArrayString;
 import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.FocusEvent;
 import com.google.gwt.event.dom.client.FocusHandler;
@@ -102,12 +101,9 @@
     super.onShowView();
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            resizeCodeMirror();
-            cm.refresh();
-          }
+        () -> {
+          resizeCodeMirror();
+          cm.refresh();
         });
     setLineLength(Patch.COMMIT_MSG.equals(path) ? 72 : prefs.lineLength());
     diffTable.refresh();
@@ -142,13 +138,10 @@
 
     cm.on(
         "scroll",
-        new Runnable() {
-          @Override
-          public void run() {
-            ScrollInfo si = cm.getScrollInfo();
-            if (autoHideDiffTableHeader) {
-              updateDiffTableHeader(si);
-            }
+        () -> {
+          ScrollInfo si = cm.getScrollInfo();
+          if (autoHideDiffTableHeader) {
+            updateDiffTableHeader(si);
           }
         });
     maybeRegisterRenderEntireFileKeyMap(cm);
@@ -186,17 +179,14 @@
     chunkManager = new UnifiedChunkManager(this, cm, diffTable.scrollbar);
 
     operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            // Estimate initial CodeMirror height, fixed up in onShowView.
-            int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
-            cm.setHeight(height);
+        () -> {
+          // Estimate initial CodeMirror height, fixed up in onShowView.
+          int height = Window.getClientHeight() - (Gerrit.getHeaderFooterHeight() + 18);
+          cm.setHeight(height);
 
-            render(diff);
-            commentManager.render(comments, prefs.expandAllComments());
-            skipManager.render(prefs.context(), diff);
-          }
+          render(diff);
+          commentManager.render(comments, prefs.expandAllComments());
+          skipManager.render(prefs.context(), diff);
         });
 
     registerCmEvents(cm);
@@ -318,24 +308,18 @@
 
   @Override
   Runnable updateActiveLine(final CodeMirror cm) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // The rendering of active lines has to be deferred. Reflow
-        // caused by adding and removing styles chokes Firefox when arrow
-        // key (or j/k) is held down. Performance on Chrome is fine
-        // without the deferral.
-        //
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
-                    cm.extras().activeLine(handle);
-                  }
-                });
-      }
+    return () -> {
+      // The rendering of active lines has to be deferred. Reflow
+      // caused by adding and removing styles chokes Firefox when arrow
+      // key (or j/k) is held down. Performance on Chrome is fine
+      // without the deferral.
+      //
+      Scheduler.get()
+          .scheduleDeferred(
+              () -> {
+                LineHandle handle = cm.getLineHandleVisualStart(cm.getCursor("end").line());
+                cm.extras().activeLine(handle);
+              });
     };
   }
 
@@ -354,14 +338,8 @@
   }
 
   @Override
-  void operation(final Runnable apply) {
-    cm.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            apply.run();
-          }
-        });
+  void operation(Runnable apply) {
+    cm.operation(apply::run);
   }
 
   @Override
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
index 3939f99..7e1a75b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedChunkManager.java
@@ -214,17 +214,14 @@
 
   @Override
   Runnable diffChunkNav(final CodeMirror cm, final Direction dir) {
-    return new Runnable() {
-      @Override
-      public void run() {
-        int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
-        int res =
-            Collections.binarySearch(
-                chunks,
-                new UnifiedDiffChunkInfo(cm.side(), 0, 0, line, false),
-                getDiffChunkComparatorCmLine());
-        diffChunkNavHelper(chunks, host, res, dir);
-      }
+    return () -> {
+      int line = cm.extras().hasActiveLine() ? cm.getLineNumber(cm.extras().activeLine()) : 0;
+      int res =
+          Collections.binarySearch(
+              chunks,
+              new UnifiedDiffChunkInfo(cm.side(), 0, 0, line, false),
+              getDiffChunkComparatorCmLine());
+      diffChunkNavHelper(chunks, host, res, dir);
     };
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
index a6912df..6d5fba3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/UnifiedCommentGroup.java
@@ -50,29 +50,26 @@
   void handleRedraw() {
     getLineWidget()
         .onRedraw(
-            new Runnable() {
-              @Override
-              public void run() {
-                if (canComputeHeight()) {
-                  if (getResizeTimer() != null) {
-                    getResizeTimer().cancel();
-                    setResizeTimer(null);
-                  }
-                  reportHeightChange();
-                } else if (getResizeTimer() == null) {
-                  setResizeTimer(
-                      new Timer() {
-                        @Override
-                        public void run() {
-                          if (canComputeHeight()) {
-                            cancel();
-                            setResizeTimer(null);
-                            reportHeightChange();
-                          }
-                        }
-                      });
-                  getResizeTimer().scheduleRepeating(5);
+            () -> {
+              if (canComputeHeight()) {
+                if (getResizeTimer() != null) {
+                  getResizeTimer().cancel();
+                  setResizeTimer(null);
                 }
+                reportHeightChange();
+              } else if (getResizeTimer() == null) {
+                setResizeTimer(
+                    new Timer() {
+                      @Override
+                      public void run() {
+                        if (canComputeHeight()) {
+                          cancel();
+                          setResizeTimer(null);
+                          reportHeightChange();
+                        }
+                      }
+                    });
+                getResizeTimer().scheduleRepeating(5);
               }
             });
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
index 511944b..ac4fb63 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/editor/EditScreen.java
@@ -49,7 +49,6 @@
 import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.core.client.Scheduler;
-import com.google.gwt.core.client.Scheduler.ScheduledCommand;
 import com.google.gwt.dom.client.Element;
 import com.google.gwt.dom.client.Style.Unit;
 import com.google.gwt.event.dom.client.ClickEvent;
@@ -318,11 +317,8 @@
   }
 
   private Runnable gotoLine() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        cmEdit.execCommand("jumpToLine");
-      }
+    return () -> {
+      cmEdit.execCommand("jumpToLine");
     };
   }
 
@@ -474,18 +470,12 @@
 
   void setTheme(final Theme newTheme) {
     cmBase.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmBase.setOption("theme", newTheme.name().toLowerCase());
-          }
+        () -> {
+          cmBase.setOption("theme", newTheme.name().toLowerCase());
         });
     cmEdit.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmEdit.setOption("theme", newTheme.name().toLowerCase());
-          }
+        () -> {
+          cmEdit.setOption("theme", newTheme.name().toLowerCase());
         });
   }
 
@@ -506,18 +496,12 @@
 
   void setShowWhitespaceErrors(final boolean show) {
     cmBase.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmBase.setOption("showTrailingSpace", show);
-          }
+        () -> {
+          cmBase.setOption("showTrailingSpace", show);
         });
     cmEdit.operation(
-        new Runnable() {
-          @Override
-          public void run() {
-            cmEdit.setOption("showTrailingSpace", show);
-          }
+        () -> {
+          cmEdit.setOption("showTrailingSpace", show);
         });
   }
 
@@ -643,29 +627,17 @@
   }
 
   private Runnable updateCursorPosition() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        // The rendering of active lines has to be deferred. Reflow
-        // caused by adding and removing styles chokes Firefox when arrow
-        // key (or j/k) is held down. Performance on Chrome is fine
-        // without the deferral.
-        //
-        Scheduler.get()
-            .scheduleDeferred(
-                new ScheduledCommand() {
-                  @Override
-                  public void execute() {
-                    cmEdit.operation(
-                        new Runnable() {
-                          @Override
-                          public void run() {
-                            updateActiveLine();
-                          }
-                        });
-                  }
-                });
-      }
+    return () -> {
+      // The rendering of active lines has to be deferred. Reflow
+      // caused by adding and removing styles chokes Firefox when arrow
+      // key (or j/k) is held down. Performance on Chrome is fine
+      // without the deferral.
+      //
+      Scheduler.get()
+          .scheduleDeferred(
+              () -> {
+                cmEdit.operation(this::updateActiveLine);
+              });
     };
   }
 
@@ -683,37 +655,34 @@
   }
 
   private Runnable save() {
-    return new Runnable() {
-      @Override
-      public void run() {
-        if (!cmEdit.isClean(generation)) {
-          close.setEnabled(false);
-          String text = cmEdit.getValue();
-          if (Patch.COMMIT_MSG.equals(path)) {
-            String trimmed = text.trim() + "\r";
-            if (!trimmed.equals(text)) {
-              text = trimmed;
-              cmEdit.setValue(text);
-            }
+    return () -> {
+      if (!cmEdit.isClean(generation)) {
+        close.setEnabled(false);
+        String text = cmEdit.getValue();
+        if (Patch.COMMIT_MSG.equals(path)) {
+          String trimmed = text.trim() + "\r";
+          if (!trimmed.equals(text)) {
+            text = trimmed;
+            cmEdit.setValue(text);
           }
-          final int g = cmEdit.changeGeneration(false);
-          ChangeEditApi.put(
-              revision.getParentKey().get(),
-              path,
-              text,
-              new GerritCallback<VoidResult>() {
-                @Override
-                public void onSuccess(VoidResult result) {
-                  generation = g;
-                  setClean(cmEdit.isClean(g));
-                }
-
-                @Override
-                public void onFailure(final Throwable caught) {
-                  close.setEnabled(true);
-                }
-              });
         }
+        final int g = cmEdit.changeGeneration(false);
+        ChangeEditApi.put(
+            revision.getParentKey().get(),
+            path,
+            text,
+            new GerritCallback<VoidResult>() {
+              @Override
+              public void onSuccess(VoidResult result) {
+                generation = g;
+                setClean(cmEdit.isClean(g));
+              }
+
+              @Override
+              public void onFailure(final Throwable caught) {
+                close.setEnabled(true);
+              }
+            });
       }
     };
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
index 738319d..4eda46b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ConfigInfo.java
@@ -57,6 +57,9 @@
   public final native InheritedBooleanInfo rejectImplicitMerges()
       /*-{ return this.reject_implicit_merges; }-*/ ;
 
+  public final native InheritedBooleanInfo enableReviewerByEmail()
+      /*-{ return this.enable_reviewer_by_email; }-*/ ;
+
   public final SubmitType submitType() {
     return SubmitType.valueOf(submitTypeRaw());
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 9676cd3..f7309ec 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Provider;
 import com.google.inject.servlet.RequestScoped;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
index f1600bc..e476f15 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 public interface WebSession {
   boolean isSignedIn();
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
index 59591cc..7884089 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,7 +30,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index b7c6be3..7f6255a 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.httpd.auth.become;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 5a0ed71..3a575a1 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -17,7 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Strings.emptyToNull;
 import static com.google.common.net.HttpHeaders.AUTHORIZATION;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -26,7 +26,7 @@
 import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.raw.HostPageServlet;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtjsonrpc.server.RPCServletUtils;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index 40b543b..3696c21 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.auth.container;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.PageLinks;
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtexpui.server.CacheHeaders;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
index a5ba2e8..53b49a4 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
@@ -39,7 +39,7 @@
     return (ServletContext)
         Proxy.newProxyInstance(
             PluginServletContext.class.getClassLoader(),
-            new Class[] {ServletContext.class, API.class},
+            new Class<?>[] {ServletContext.class, API.class},
             new Handler(plugin, contextPath));
   }
 
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java
index d2e3b58..85453fb 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/raw/BazelBuild.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.httpd.raw;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.escape.Escaper;
@@ -25,7 +26,10 @@
 import java.io.InputStream;
 import java.io.InterruptedIOException;
 import java.io.PrintWriter;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
+import java.util.Properties;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.slf4j.Logger;
@@ -117,8 +121,24 @@
     }
   }
 
-  private ProcessBuilder newBuildProcess(Label label) {
-    return new ProcessBuilder("bazel", "build", label.fullName());
+  private Properties loadBuildProperties(Path propPath) throws IOException {
+    Properties properties = new Properties();
+    try (InputStream in = Files.newInputStream(propPath)) {
+      properties.load(in);
+    } catch (NoSuchFileException e) {
+      // Ignore; will be run from PATH, with a descriptive error if it fails.
+    }
+    return properties;
+  }
+
+  private ProcessBuilder newBuildProcess(Label label) throws IOException {
+    Properties properties = loadBuildProperties(sourceRoot.resolve(".bazel_path"));
+    String bazel = firstNonNull(properties.getProperty("bazel"), "bazel");
+    ProcessBuilder proc = new ProcessBuilder(bazel, "build", label.fullName());
+    if (properties.containsKey("PATH")) {
+      proc.environment().put("PATH", properties.getProperty("PATH"));
+    }
+    return proc;
   }
 
   /** returns the root relative path to the artifact for the given label */
diff --git a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
index eecaf36..a7af056 100644
--- a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -54,7 +54,7 @@
 
 /** Main class for a JAR file to run code from "WEB-INF/lib". */
 public final class GerritLauncher {
-  private static final String pkg = "com.google.gerrit.pgm";
+  private static final String PKG = "com.google.gerrit.pgm";
   public static final String NOT_ARCHIVED = "NOT_ARCHIVED";
 
   private static ClassLoader daemonClassLoader;
@@ -173,17 +173,17 @@
     try {
       try {
         String cn = programClassName(name);
-        clazz = Class.forName(pkg + "." + cn, true, loader);
+        clazz = Class.forName(PKG + "." + cn, true, loader);
       } catch (ClassNotFoundException cnfe) {
         if (name.equals(name.toLowerCase())) {
-          clazz = Class.forName(pkg + "." + name, true, loader);
+          clazz = Class.forName(PKG + "." + name, true, loader);
         } else {
           throw cnfe;
         }
       }
     } catch (ClassNotFoundException cnfe) {
       System.err.println("fatal: unknown command " + name);
-      System.err.println("      (no " + pkg + "." + name + ")");
+      System.err.println("      (no " + PKG + "." + name + ")");
       return 1;
     }
 
@@ -200,7 +200,8 @@
       if ((main.getModifiers() & Modifier.STATIC) == Modifier.STATIC) {
         res = main.invoke(null, new Object[] {argv});
       } else {
-        res = main.invoke(clazz.getConstructor(new Class[] {}).newInstance(), new Object[] {argv});
+        res =
+            main.invoke(clazz.getConstructor(new Class<?>[] {}).newInstance(), new Object[] {argv});
       }
     } catch (InvocationTargetException ite) {
       if (ite.getCause() instanceof Exception) {
@@ -604,7 +605,7 @@
     return resolveInSourceRoot("eclipse-out");
   }
 
-  static String SOURCE_ROOT_RESOURCE = "/gerrit-launcher/workspace-root.txt";
+  static final String SOURCE_ROOT_RESOURCE = "/gerrit-launcher/workspace-root.txt";
 
   /**
    * Locate a path in the source tree.
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 5c3183a..12636b8 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -121,28 +121,25 @@
       @SuppressWarnings("unused") // Error handling within Runnable.
       Future<?> possiblyIgnoredError =
           autoCommitExecutor.scheduleAtFixedRate(
-              new Runnable() {
-                @Override
-                public void run() {
+              () -> {
+                try {
+                  if (autoCommitWriter.hasUncommittedChanges()) {
+                    autoCommitWriter.manualFlush();
+                    autoCommitWriter.commit();
+                  }
+                } catch (IOException e) {
+                  log.error("Error committing " + index + " Lucene index", e);
+                } catch (OutOfMemoryError e) {
+                  log.error("Error committing " + index + " Lucene index", e);
                   try {
-                    if (autoCommitWriter.hasUncommittedChanges()) {
-                      autoCommitWriter.manualFlush();
-                      autoCommitWriter.commit();
-                    }
-                  } catch (IOException e) {
-                    log.error("Error committing " + index + " Lucene index", e);
-                  } catch (OutOfMemoryError e) {
-                    log.error("Error committing " + index + " Lucene index", e);
-                    try {
-                      autoCommitWriter.close();
-                    } catch (IOException e2) {
-                      log.error(
-                          "SEVERE: Error closing "
-                              + index
-                              + " Lucene index after OOM;"
-                              + " index may be corrupted.",
-                          e);
-                    }
+                    autoCommitWriter.close();
+                  } catch (IOException e2) {
+                    log.error(
+                        "SEVERE: Error closing "
+                            + index
+                            + " Lucene index after OOM;"
+                            + " index may be corrupted.",
+                        e);
                   }
                 }
               },
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 96986a9..3afcb07 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -61,9 +61,9 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -121,6 +121,7 @@
   private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
   private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
   private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
+  private static final String REVIEWER_BY_EMAIL_FIELD = ChangeField.REVIEWER_BY_EMAIL.getName();
   private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
   private static final String STAR_FIELD = ChangeField.STAR.getName();
   private static final String SUBMIT_RECORD_LENIENT_FIELD =
@@ -147,7 +148,7 @@
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
 
-  @AssistedInject
+  @Inject
   LuceneChangeIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
@@ -459,6 +460,9 @@
     if (fields.contains(REVIEWER_FIELD)) {
       decodeReviewers(doc, cd);
     }
+    if (fields.contains(REVIEWER_BY_EMAIL_FIELD)) {
+      decodeReviewersByEmail(doc, cd);
+    }
     decodeSubmitRecords(
         doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
     decodeSubmitRecords(
@@ -555,6 +559,13 @@
             FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
   }
 
+  private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setReviewersByEmail(
+        ChangeField.parseReviewerByEmailFieldValues(
+            FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
+                .transform(IndexableField::stringValue)));
+  }
+
   private void decodeSubmitRecords(
       ListMultimap<String, IndexableField> doc,
       String field,
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index 0391831..3cb8816 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index e862bac..878f9ee 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index efe8c5f..a3bf361 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
diff --git a/gerrit-patch-jgit/BUILD b/gerrit-patch-jgit/BUILD
index e3d3cbd..1a8fcd4 100644
--- a/gerrit-patch-jgit/BUILD
+++ b/gerrit-patch-jgit/BUILD
@@ -28,12 +28,12 @@
     name = "jgit_edit_src",
     outs = ["edit.srcjar"],
     cmd = " && ".join([
-        "unzip -qd $$TMP $(location @jgit//jar:src) " +
+        "unzip -qd $$TMP $(location //lib/jgit/org.eclipse.jgit:jgit-source) " +
         "org/eclipse/jgit/diff/Edit.java",
         "cd $$TMP",
         "zip -Dq $$ROOT/$@ org/eclipse/jgit/diff/Edit.java",
     ]),
-    tools = ["@jgit//jar:src"],
+    tools = ["//lib/jgit/org.eclipse.jgit:jgit-source"],
 )
 
 java_library(
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index 22f0f3e..475ff2b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -228,12 +228,9 @@
     try {
       start();
       RuntimeShutdown.add(
-          new Runnable() {
-            @Override
-            public void run() {
-              log.info("caught shutdown, cleaning up");
-              stop();
-            }
+          () -> {
+            log.info("caught shutdown, cleaning up");
+            stop();
           });
 
       log.info("Gerrit Code Review " + myVersion() + " ready");
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
index b1a50d7..004486b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
@@ -45,16 +45,13 @@
     manager.add(dbInjector);
     manager.start();
     RuntimeShutdown.add(
-        new Runnable() {
-          @Override
-          public void run() {
-            try {
-              System.in.close();
-            } catch (IOException e) {
-              // Ignored
-            }
-            manager.stop();
+        () -> {
+          try {
+            System.in.close();
+          } catch (IOException e) {
+            // Ignored
           }
+          manager.stop();
         });
     final QueryShell shell = shellFactory().create(System.in, System.out);
     shell.setOutputFormat(format);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 7457f40..4e18ddc 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -14,16 +14,19 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsBatchUpdate;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsBatchUpdate;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
 import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.Collection;
@@ -37,6 +40,8 @@
 
   @Inject private SchemaFactory<ReviewDb> database;
 
+  @Inject private ExternalIds externalIds;
+
   @Inject private ExternalIdsBatchUpdate externalIdsBatchUpdate;
 
   @Override
@@ -44,10 +49,22 @@
     Injector dbInjector = createDbInjector(MULTI_USER);
     manager.add(dbInjector, dbInjector.createChildInjector(SchemaVersionCheck.module()));
     manager.start();
-    dbInjector.injectMembers(this);
+    dbInjector
+        .createChildInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                // The LocalUsernamesToLowerCase program needs to access all external IDs only
+                // once to update them. After the update they are not accessed again. Hence the
+                // LocalUsernamesToLowerCase program doesn't benefit from caching external IDs and
+                // the external ID cache can be disabled.
+                install(DisabledExternalIdCache.module());
+              }
+            })
+        .injectMembers(this);
 
     try (ReviewDb db = database.open()) {
-      Collection<ExternalId> todo = ExternalId.from(db.accountExternalIds().all().toList());
+      Collection<ExternalId> todo = externalIds.all(db);
       monitor.beginTask("Converting local usernames", todo.size());
 
       for (ExternalId extId : todo) {
@@ -56,9 +73,9 @@
       }
 
       externalIdsBatchUpdate.commit(db, "Convert local usernames to lower case");
+      monitor.endTask();
+      manager.stop();
     }
-    monitor.endTask();
-    manager.stop();
     return 0;
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
index 07e7921..fb524a3 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
@@ -85,13 +85,7 @@
     Injector dbInjector = createDbInjector(SINGLE_USER);
     manager.add(dbInjector);
     manager.start();
-    RuntimeShutdown.add(
-        new Runnable() {
-          @Override
-          public void run() {
-            manager.stop();
-          }
-        });
+    RuntimeShutdown.add(manager::stop);
     dbInjector.injectMembers(this);
 
     ProgressMonitor progress = new TextProgressMonitor();
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
index 45206c9..b80bf35 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
@@ -40,6 +40,9 @@
         .annotatedWith(Names.named("jdbc"))
         .to(JDBCInitializer.class);
     bind(DatabaseConfigInitializer.class)
+        .annotatedWith(Names.named("mariadb"))
+        .to(MariaDbInitializer.class);
+    bind(DatabaseConfigInitializer.class)
         .annotatedWith(Names.named("mysql"))
         .to(MySqlInitializer.class);
     bind(DatabaseConfigInitializer.class)
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 86c5f45e..5bedb1b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.pgm.init;
 
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+import static com.google.gerrit.server.account.externalids.ExternalId.toAccountExternalIds;
 
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIds;
-import com.google.gerrit.server.account.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -61,9 +61,9 @@
       try (Repository repo = new FileRepository(path);
           RevWalk rw = new RevWalk(repo);
           ObjectInserter ins = repo.newObjectInserter()) {
-        ObjectId rev = ExternalIds.readRevision(repo);
+        ObjectId rev = ExternalIdReader.readRevision(repo);
 
-        NoteMap noteMap = ExternalIds.readNoteMap(rw, rev);
+        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
         for (ExternalId extId : extIds) {
           ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
         }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 68b2b96..c6b2784 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gwtorm.server.SchemaFactory;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
index 5565158..349ab55 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
@@ -85,6 +85,8 @@
 
     if (dci instanceof MySqlInitializer) {
       libraries.mysqlDriver.downloadRequired();
+    } else if (dci instanceof MariaDbInitializer) {
+      libraries.mariadbDriver.downloadRequired();
     } else if (dci instanceof OracleInitializer) {
       libraries.oracleDriver.downloadRequired();
     } else if (dci instanceof DB2Initializer) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
index 67e0eb4..d3d4853 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSshd.java
@@ -32,7 +32,7 @@
 import java.net.InetSocketAddress;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import org.apache.sshd.common.util.SecurityUtils;
+import org.apache.sshd.common.util.security.SecurityUtils;
 import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 
 /** Initialize the {@code sshd} configuration section. */
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java
index 4659ee3..e3a1d66 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/JDBCInitializer.java
@@ -37,6 +37,8 @@
         database.set("driver", "org.apache.derby.jdbc.EmbeddedDriver");
       } else if (url.startsWith("jdbc:h2:")) {
         database.set("driver", "org.h2.Driver");
+      } else if (url.startsWith("jdbc:mariadb:")) {
+        database.set("driver", "org.mariadb.jdbc.Driver");
       } else if (url.startsWith("jdbc:mysql:")) {
         database.set("driver", "com.mysql.jdbc.Driver");
       } else if (url.startsWith("jdbc:postgresql:")) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
index 526f172..3259f96 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
@@ -43,6 +43,7 @@
   /* final */ LibraryDownloader db2Driver;
   /* final */ LibraryDownloader db2DriverLicense;
   /* final */ LibraryDownloader hanaDriver;
+  /* final */ LibraryDownloader mariadbDriver;
   /* final */ LibraryDownloader mysqlDriver;
   /* final */ LibraryDownloader oracleDriver;
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MariaDbInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MariaDbInitializer.java
new file mode 100644
index 0000000..db32113
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/MariaDbInitializer.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static com.google.gerrit.pgm.init.api.InitUtil.username;
+
+import com.google.gerrit.pgm.init.api.Section;
+
+class MariaDbInitializer implements DatabaseConfigInitializer {
+
+  @Override
+  public void initConfig(Section databaseSection) {
+    final String defPort = "(mariadb default)";
+    databaseSection.string("Server hostname", "hostname", "localhost");
+    databaseSection.string("Server port", "port", defPort, true);
+    databaseSection.string("Database name", "database", "reviewdb");
+    databaseSection.string("Database username", "username", username());
+    databaseSection.password("username", "password");
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index aec0731..1574131 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
 import com.google.gerrit.server.change.ChangeJson;
@@ -146,6 +147,7 @@
 
     install(new BatchGitModule());
     install(new DefaultCacheFactory.Module());
+    install(new ExternalIdModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
     install(new PrologModule());
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
index f4564f5..26ac9d6 100644
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
@@ -18,6 +18,12 @@
   sha1 = b0878056f15616989144d6114d36d3942321d0d1
   remove = mysql-connector-java-.*[.]jar
 
+[library "mariadbDriver"]
+  name = MariaDB Connector/J 1.5.9
+  url = https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/1.5.9/mariadb-java-client-1.5.9.jar
+  sha1 = 75d4d6e4cdb9a551a102e92a14c640768174e214
+  remove = mariadb-java-client-.*[.]jar
+
 [library "oracleDriver"]
   name = Oracle JDBC driver 11g Release 2 (11.2.0)
   url = file:///u01/app/oracle/product/11.2.0/xe/jdbc/lib/ojdbc6.jar
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index 9309921..6a83e4d 100644
--- a/gerrit-plugin-api/pom.xml
+++ b/gerrit-plugin-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.14-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/gerrit-plugin-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
index 4b104c6..964e0d2 100644
--- a/gerrit-plugin-gwtui/pom.xml
+++ b/gerrit-plugin-gwtui/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.14-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin GWT UI</name>
   <description>Common Classes for Gerrit GWT UI Plugins</description>
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
index e1f894f..f1c7056 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
@@ -45,7 +45,7 @@
  */
 public final class Account {
   public static final String USER_NAME_PATTERN_FIRST = "[a-zA-Z0-9]";
-  public static final String USER_NAME_PATTERN_REST = "[a-zA-Z0-9._-]";
+  public static final String USER_NAME_PATTERN_REST = "[a-zA-Z0-9._@-]";
   public static final String USER_NAME_PATTERN_LAST = "[a-zA-Z0-9]";
 
   /** Regular expression that {@link #userName} must match. */
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index 9655edd..6495cf1 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -512,6 +512,10 @@
   @Column(id = 19, notNull = false)
   protected Account.Id assignee;
 
+  /** Whether the change is private. */
+  @Column(id = 20)
+  protected boolean isPrivate;
+
   /** @see com.google.gerrit.server.notedb.NoteDbChangeState */
   @Column(id = 101, notNull = false, length = Integer.MAX_VALUE)
   protected String noteDbState;
@@ -548,6 +552,7 @@
     originalSubject = other.originalSubject;
     submissionId = other.submissionId;
     topic = other.topic;
+    isPrivate = other.isPrivate;
     noteDbState = other.noteDbState;
   }
 
@@ -694,6 +699,14 @@
     this.topic = topic;
   }
 
+  public boolean isPrivate() {
+    return isPrivate;
+  }
+
+  public void setPrivate(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
   public String getNoteDbState() {
     return noteDbState;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
index b2c72d9..cadd52c 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -131,10 +131,10 @@
   }
 
   public static class Range {
-    public int startLine;
-    public int startChar;
-    public int endLine;
-    public int endChar;
+    public int startLine; // 1-based, inclusive
+    public int startChar; // 0-based, inclusive
+    public int endLine; // 1-based, exclusive
+    public int endChar; // 0-based, exclusive
 
     public Range(Range r) {
       this(r.startLine, r.startChar, r.endLine, r.endChar);
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
index ba83c58..0706032 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
@@ -99,6 +99,8 @@
 
   protected InheritableBoolean rejectImplicitMerges;
 
+  protected InheritableBoolean enableReviewerByEmail;
+
   protected Project() {}
 
   public Project(Project.NameKey nameKey) {
@@ -112,6 +114,7 @@
     createNewChangeForAllNotInTarget = InheritableBoolean.INHERIT;
     enableSignedPush = InheritableBoolean.INHERIT;
     requireSignedPush = InheritableBoolean.INHERIT;
+    enableReviewerByEmail = InheritableBoolean.INHERIT;
   }
 
   public Project.NameKey getNameKey() {
@@ -154,6 +157,10 @@
     return rejectImplicitMerges;
   }
 
+  public InheritableBoolean getEnableReviewerByEmail() {
+    return enableReviewerByEmail;
+  }
+
   public void setUseContributorAgreements(final InheritableBoolean u) {
     useContributorAgreements = u;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
index 9124301..e21faaf 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
@@ -30,6 +30,9 @@
   @Query("WHERE accountId = ?")
   ResultSet<AccountExternalId> byAccount(Account.Id id) throws OrmException;
 
+  @Query("WHERE emailAddress = ?")
+  ResultSet<AccountExternalId> byEmailAddress(String email) throws OrmException;
+
   @Query
   ResultSet<AccountExternalId> all() throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
index deceab9..2fcf53c 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
@@ -21,6 +21,9 @@
 CREATE INDEX account_external_ids_byAccount
 ON account_external_ids (account_id);
 
+--    covers:             byEmailAddress
+CREATE INDEX account_external_ids_byEmail
+ON account_external_ids (email_address);
 
 -- *********************************************************************
 -- AccountGroupMemberAccess
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
index 1ec8ea6..3be3c26 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
@@ -25,6 +25,10 @@
 ON account_external_ids (account_id)
 #
 
+--    covers:             byEmailAddress
+CREATE INDEX account_external_ids_byEmail
+ON account_external_ids (email_address)
+#
 
 -- *********************************************************************
 -- AccountGroupMemberAccess
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
index a11c86b..641c613 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
@@ -68,6 +68,10 @@
 CREATE INDEX account_external_ids_byAccount
 ON account_external_ids (account_id);
 
+--    covers:             byEmailAddress
+CREATE INDEX account_external_ids_byEmail
+ON account_external_ids (email_address);
+
 
 -- *********************************************************************
 -- AccountGroupMemberAccess
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
index 9773869..6357df9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
@@ -79,19 +79,16 @@
    * @param value only value of the metric.
    * @param desc description of the metric.
    */
-  public <V> void newConstantMetric(String name, final V value, Description desc) {
+  public <V> void newConstantMetric(String name, V value, Description desc) {
     desc.setConstant();
 
     @SuppressWarnings("unchecked")
     Class<V> type = (Class<V>) value.getClass();
-    final CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
+    CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
     newTrigger(
         metric,
-        new Runnable() {
-          @Override
-          public void run() {
-            metric.set(value);
-          }
+        () -> {
+          metric.set(value);
         });
   }
 
@@ -116,15 +113,12 @@
    * @param trigger function to compute the value of the metric.
    */
   public <V> void newCallbackMetric(
-      String name, Class<V> valueClass, Description desc, final Supplier<V> trigger) {
-    final CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
+      String name, Class<V> valueClass, Description desc, Supplier<V> trigger) {
+    CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
     newTrigger(
         metric,
-        new Runnable() {
-          @Override
-          public void run() {
-            metric.set(trigger.get());
-          }
+        () -> {
+          metric.set(trigger.get());
         });
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
index cbd2378..7256e8c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanProvider.java
@@ -52,10 +52,10 @@
   private OperatingSystemMXBeanProvider(OperatingSystemMXBean sys)
       throws ReflectiveOperationException {
     this.sys = sys;
-    getProcessCpuTime = sys.getClass().getMethod("getProcessCpuTime", new Class[] {});
+    getProcessCpuTime = sys.getClass().getMethod("getProcessCpuTime", new Class<?>[] {});
     getProcessCpuTime.setAccessible(true);
     getOpenFileDescriptorCount =
-        sys.getClass().getMethod("getOpenFileDescriptorCount", new Class[] {});
+        sys.getClass().getMethod("getOpenFileDescriptorCount", new Class<?>[] {});
     getOpenFileDescriptorCount.setAccessible(true);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 11f8e50..e0371fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -147,23 +147,20 @@
     metrics.newTrigger(
         ImmutableSet.<CallbackMetric<?>>of(
             heapCommitted, heapUsed, nonHeapCommitted, nonHeapUsed, objectPendingFinalizationCount),
-        new Runnable() {
-          @Override
-          public void run() {
-            try {
-              MemoryUsage stats = memory.getHeapMemoryUsage();
-              heapCommitted.set(stats.getCommitted());
-              heapUsed.set(stats.getUsed());
-            } catch (IllegalArgumentException e) {
-              // MXBean may throw due to a bug in Java 7; ignore.
-            }
-
-            MemoryUsage stats = memory.getNonHeapMemoryUsage();
-            nonHeapCommitted.set(stats.getCommitted());
-            nonHeapUsed.set(stats.getUsed());
-
-            objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
+        () -> {
+          try {
+            MemoryUsage stats = memory.getHeapMemoryUsage();
+            heapCommitted.set(stats.getCommitted());
+            heapUsed.set(stats.getUsed());
+          } catch (IllegalArgumentException e) {
+            // MXBean may throw due to a bug in Java 7; ignore.
           }
+
+          MemoryUsage stats = memory.getNonHeapMemoryUsage();
+          nonHeapCommitted.set(stats.getCommitted());
+          nonHeapUsed.set(stats.getUsed());
+
+          objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
         });
   }
 
@@ -187,18 +184,15 @@
     metrics.newTrigger(
         gcCount,
         gcTime,
-        new Runnable() {
-          @Override
-          public void run() {
-            for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
-              long count = gc.getCollectionCount();
-              if (count != -1) {
-                gcCount.set(gc.getName(), count);
-              }
-              long time = gc.getCollectionTime();
-              if (time != -1) {
-                gcTime.set(gc.getName(), time);
-              }
+        () -> {
+          for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
+            long count = gc.getCollectionCount();
+            if (count != -1) {
+              gcCount.set(gc.getName(), count);
+            }
+            long time = gc.getCollectionTime();
+            if (time != -1) {
+              gcTime.set(gc.getName(), time);
             }
           }
         });
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
index 34fcb52..98ec569 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
@@ -119,19 +119,13 @@
           GitRepositoryManager gitMgr = env.getArgs().getGitRepositoryManager();
           Change change = getChange(engine);
           Project.NameKey projectKey = change.getProject();
-          final Repository repo;
+          Repository repo;
           try {
             repo = gitMgr.openRepository(projectKey);
           } catch (IOException e) {
             throw new SystemException(e.getMessage());
           }
-          env.addToCleanup(
-              new Runnable() {
-                @Override
-                public void run() {
-                  repo.close();
-                }
-              });
+          env.addToCleanup(repo::close);
           return repo;
         }
       };
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 910fbc2..1ef284c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -322,7 +322,7 @@
         accountId,
         ps.getUploader());
     if (approvals.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
     checkApprovals(approvals, changeCtl);
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
index 029b54d..7e1be17 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.CapabilityControl;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.inject.servlet.RequestScoped;
 import java.util.function.Consumer;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java
new file mode 100644
index 0000000..c16c9c8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Table;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import java.sql.Timestamp;
+
+/**
+ * Set of reviewers on a change that do not have a Gerrit account and were added by email instead.
+ *
+ * <p>A given account may appear in multiple states and at different timestamps. No reviewers with
+ * state {@link ReviewerStateInternal#REMOVED} are ever exposed by this interface.
+ */
+public class ReviewerByEmailSet {
+  private static final ReviewerByEmailSet EMPTY = new ReviewerByEmailSet(ImmutableTable.of());
+
+  public static ReviewerByEmailSet fromTable(
+      Table<ReviewerStateInternal, Address, Timestamp> table) {
+    return new ReviewerByEmailSet(table);
+  }
+
+  public static ReviewerByEmailSet empty() {
+    return EMPTY;
+  }
+
+  private final ImmutableTable<ReviewerStateInternal, Address, Timestamp> table;
+  private ImmutableSet<Address> users;
+
+  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Timestamp> table) {
+    this.table = ImmutableTable.copyOf(table);
+  }
+
+  public ImmutableSet<Address> all() {
+    if (users == null) {
+      // Idempotent and immutable, don't bother locking.
+      users = ImmutableSet.copyOf(table.columnKeySet());
+    }
+    return users;
+  }
+
+  public ImmutableSet<Address> byState(ReviewerStateInternal state) {
+    return table.row(state).keySet();
+  }
+
+  public ImmutableTable<ReviewerStateInternal, Address, Timestamp> asTable() {
+    return table;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof ReviewerByEmailSet) && table.equals(((ReviewerByEmailSet) o).table);
+  }
+
+  @Override
+  public int hashCode() {
+    return table.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + table;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
index 6b3a58f..4ab42f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
@@ -35,6 +35,8 @@
 @SuppressWarnings("deprecation")
 @Singleton
 public class Sequences {
+  public static final String CHANGES = "changes";
+
   private final Provider<ReviewDb> db;
   private final NotesMigration migration;
   private final RepoSequence changeSeq;
@@ -54,7 +56,7 @@
         new RepoSequence(
             repoManager,
             allProjects,
-            "changes",
+            CHANGES,
             new RepoSequence.Seed() {
               @Override
               public int get() throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
index 0f9ec8d..e61736d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.Inject;
 import java.util.Collection;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
index d45ecd8..950eac7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
@@ -14,13 +14,15 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -29,7 +31,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import org.slf4j.Logger;
@@ -78,32 +79,23 @@
 
   static class Loader extends CacheLoader<String, Set<Account.Id>> {
     private final SchemaFactory<ReviewDb> schema;
-    private final Provider<InternalAccountQuery> accountQueryProvider;
+
+    // This must be a provider to prevent a cyclic dependency within Google-internal glue code.
+    private final Provider<ExternalIds> externalIds;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema, Provider<InternalAccountQuery> accountQueryProvider) {
+    Loader(SchemaFactory<ReviewDb> schema, Provider<ExternalIds> externalIds) {
       this.schema = schema;
-      this.accountQueryProvider = accountQueryProvider;
+      this.externalIds = externalIds;
     }
 
     @Override
     public Set<Account.Id> load(String email) throws Exception {
       try (ReviewDb db = schema.open()) {
-        Set<Account.Id> r = new HashSet<>();
-        for (Account a : db.accounts().byPreferredEmail(email)) {
-          r.add(a.getId());
-        }
-        for (AccountState accountState : accountQueryProvider.get().byEmailPrefix(email)) {
-          if (accountState
-              .getExternalIds()
-              .stream()
-              .filter(e -> email.equals(e.email()))
-              .findAny()
-              .isPresent()) {
-            r.add(accountState.getAccount().getId());
-          }
-        }
-        return ImmutableSet.copyOf(r);
+        return Streams.concat(
+                Streams.stream(db.accounts().byPreferredEmail(email)).map(a -> a.getId()),
+                externalIds.get().byEmail(db, email).stream().map(e -> e.accountId()))
+            .collect(toImmutableSet());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 245a0be..b2f1bae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
@@ -27,6 +27,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -149,6 +150,7 @@
     private final GeneralPreferencesLoader loader;
     private final LoadingCache<String, Optional<Account.Id>> byName;
     private final Provider<WatchConfig.Accessor> watchConfig;
+    private final ExternalIds externalIds;
 
     @Inject
     ByIdLoader(
@@ -156,12 +158,14 @@
         GroupCache groupCache,
         GeneralPreferencesLoader loader,
         @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
-        Provider<WatchConfig.Accessor> watchConfig) {
+        Provider<WatchConfig.Accessor> watchConfig,
+        ExternalIds externalIds) {
       this.schema = sf;
       this.groupCache = groupCache;
       this.loader = loader;
       this.byName = byUsername;
       this.watchConfig = watchConfig;
+      this.externalIds = externalIds;
     }
 
     @Override
@@ -184,9 +188,6 @@
         return missing(who);
       }
 
-      Set<ExternalId> externalIds =
-          ExternalId.from(db.accountExternalIds().byAccount(who).toList());
-
       Set<AccountGroup.UUID> internalGroups = new HashSet<>();
       for (AccountGroupMember g : db.accountGroupMembers().byAccount(who)) {
         final AccountGroup.Id groupId = g.getAccountGroupId();
@@ -205,7 +206,10 @@
       }
 
       return new AccountState(
-          account, internalGroups, externalIds, watchConfig.get().getProjectWatches(who));
+          account,
+          internalGroups,
+          externalIds.byAccount(db, who),
+          watchConfig.get().getProjectWatches(who));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index 77d28f9..944d008 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.account;
 
-import static java.util.stream.Collectors.toSet;
-
 import com.google.common.base.Strings;
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
@@ -29,6 +27,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -60,6 +61,7 @@
   private final AtomicBoolean awaitsFirstAccountCheck;
   private final AuditService auditService;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   @Inject
@@ -73,6 +75,7 @@
       ProjectCache projectCache,
       AuditService auditService,
       Provider<InternalAccountQuery> accountQueryProvider,
+      ExternalIds externalIds,
       ExternalIdsUpdate.Server externalIdsUpdateFactory) {
     this.schema = schema;
     this.byIdCache = byIdCache;
@@ -84,6 +87,7 @@
     this.awaitsFirstAccountCheck = new AtomicBoolean(true);
     this.auditService = auditService;
     this.accountQueryProvider = accountQueryProvider;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
@@ -227,8 +231,7 @@
     try {
       db.accounts().upsert(Collections.singleton(account));
 
-      ExternalId existingExtId =
-          ExternalId.from(db.accountExternalIds().get(extId.key().asAccountExternalIdKey()));
+      ExternalId existingExtId = externalIds.get(db, extId.key());
       if (existingExtId != null && !existingExtId.accountId().equals(extId.accountId())) {
         // external ID is assigned to another account, do not overwrite
         db.accounts().delete(Collections.singleton(account));
@@ -404,10 +407,7 @@
       throws OrmException, AccountException, IOException, ConfigInvalidException {
     try (ReviewDb db = schema.open()) {
       Collection<ExternalId> filteredExtIdsByScheme =
-          ExternalId.from(db.accountExternalIds().byAccount(to).toList())
-              .stream()
-              .filter(e -> e.isScheme(who.getExternalIdKey().scheme()))
-              .collect(toSet());
+          externalIds.byAccount(db, to, who.getExternalIdKey().scheme());
 
       if (!filteredExtIdsByScheme.isEmpty()
           && (filteredExtIdsByScheme.size() > 1
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
index 4b9b0fb..1eaf34f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Function;
 import com.google.common.base.Strings;
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
index d1dd4b0..4dd9926 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 /**
  * Information for {@link AccountManager#authenticate(AuthRequest)}.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
index 4aced52..2b1bc96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 /** Result from {@link AccountManager#authenticate(AuthRequest)}. */
 public class AuthResult {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
index f60ee45..1a02ea1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static java.util.stream.Collectors.toSet;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtjsonrpc.common.VoidResult;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -47,6 +49,7 @@
 
   private final AccountCache accountCache;
   private final SshKeyCache sshKeyCache;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
   private final ReviewDb db;
@@ -57,12 +60,14 @@
   ChangeUserName(
       AccountCache accountCache,
       SshKeyCache sshKeyCache,
+      ExternalIds externalIds,
       ExternalIdsUpdate.Server externalIdsUpdateFactory,
       @Assisted ReviewDb db,
       @Assisted IdentifiedUser user,
       @Nullable @Assisted String newUsername) {
     this.accountCache = accountCache;
     this.sshKeyCache = sshKeyCache;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.db = db;
     this.user = user;
@@ -73,11 +78,7 @@
   public VoidResult call()
       throws OrmException, NameAlreadyUsedException, InvalidUserNameException, IOException,
           ConfigInvalidException {
-    Collection<ExternalId> old =
-        ExternalId.from(db.accountExternalIds().byAccount(user.getAccountId()).toList())
-            .stream()
-            .filter(e -> e.isScheme(SCHEME_USERNAME))
-            .collect(toSet());
+    Collection<ExternalId> old = externalIds.byAccount(db, user.getAccountId(), SCHEME_USERNAME);
     if (!old.isEmpty()) {
       throw new IllegalStateException(USERNAME_CANNOT_BE_CHANGED);
     }
@@ -100,8 +101,7 @@
       } catch (OrmDuplicateKeyException dupeErr) {
         // If we are using this identity, don't report the exception.
         //
-        ExternalId other =
-            ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
+        ExternalId other = externalIds.get(db, key);
         if (other != null && other.accountId().equals(user.getAccountId())) {
           return VoidResult.INSTANCE;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
index 9e7e9a4d..a23d882 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 
 import com.google.gerrit.audit.AuditService;
 import com.google.gerrit.common.TimeUtil;
@@ -36,6 +36,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -71,6 +74,7 @@
   private final AccountLoader.Factory infoLoader;
   private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
   private final AuditService auditService;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
   private final String username;
 
@@ -87,6 +91,7 @@
       AccountLoader.Factory infoLoader,
       DynamicSet<AccountExternalIdCreator> externalIdCreators,
       AuditService auditService,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdateFactory,
       @Assisted String username) {
     this.db = db;
@@ -100,6 +105,7 @@
     this.infoLoader = infoLoader;
     this.externalIdCreators = externalIdCreators;
     this.auditService = auditService;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.username = username;
   }
@@ -125,13 +131,11 @@
     Account.Id id = new Account.Id(db.nextAccountId());
 
     ExternalId extUser = ExternalId.createUsername(username, id, input.httpPassword);
-    if (db.accountExternalIds().get(extUser.key().asAccountExternalIdKey()) != null) {
+    if (externalIds.get(db, extUser.key()) != null) {
       throw new ResourceConflictException("username '" + username + "' already exists");
     }
     if (input.email != null) {
-      if (db.accountExternalIds()
-              .get(ExternalId.Key.create(SCHEME_MAILTO, input.email).asAccountExternalIdKey())
-          != null) {
+      if (externalIds.get(db, ExternalId.Key.create(SCHEME_MAILTO, input.email)) != null) {
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
       }
       if (!OutgoingEmailValidator.isValid(input.email)) {
@@ -158,7 +162,7 @@
       } catch (OrmDuplicateKeyException duplicateKey) {
         try {
           externalIdsUpdate.delete(db, extUser);
-        } catch (IOException | ConfigInvalidException | OrmException cleanupError) {
+        } catch (IOException | ConfigInvalidException cleanupError) {
           // Ignored
         }
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index 794a2c1..bfdf06c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -27,6 +27,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.DeleteEmail.Input;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -43,17 +45,20 @@
   private final Realm realm;
   private final Provider<ReviewDb> dbProvider;
   private final AccountManager accountManager;
+  private final ExternalIds externalIds;
 
   @Inject
   DeleteEmail(
       Provider<CurrentUser> self,
       Realm realm,
       Provider<ReviewDb> dbProvider,
-      AccountManager accountManager) {
+      AccountManager accountManager,
+      ExternalIds externalIds) {
     this.self = self;
     this.realm = realm;
     this.dbProvider = dbProvider;
     this.accountManager = accountManager;
+    this.externalIds = externalIds;
   }
 
   @Override
@@ -74,13 +79,9 @@
     }
 
     Set<ExternalId> extIds =
-        dbProvider
-            .get()
-            .accountExternalIds()
-            .byAccount(user.getAccountId())
-            .toList()
+        externalIds
+            .byAccount(dbProvider.get(), user.getAccountId())
             .stream()
-            .map(ExternalId::from)
             .filter(e -> email.equals(e.email()))
             .collect(toSet());
     if (extIds.isEmpty()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
index 42726dc..dc7b7ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -27,6 +27,9 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -39,6 +42,7 @@
 public class DeleteExternalIds implements RestModifyView<AccountResource, List<String>> {
   private final AccountByEmailCache accountByEmailCache;
   private final AccountCache accountCache;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
@@ -47,41 +51,39 @@
   DeleteExternalIds(
       AccountByEmailCache accountByEmailCache,
       AccountCache accountCache,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdateFactory,
       Provider<CurrentUser> self,
       Provider<ReviewDb> dbProvider) {
     this.accountByEmailCache = accountByEmailCache;
     this.accountCache = accountCache;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.self = self;
     this.dbProvider = dbProvider;
   }
 
   @Override
-  public Response<?> apply(AccountResource resource, List<String> externalIds)
+  public Response<?> apply(AccountResource resource, List<String> extIds)
       throws RestApiException, IOException, OrmException, ConfigInvalidException {
     if (self.get() != resource.getUser()) {
       throw new AuthException("not allowed to delete external IDs");
     }
 
-    if (externalIds == null || externalIds.size() == 0) {
+    if (extIds == null || extIds.size() == 0) {
       throw new BadRequestException("external IDs are required");
     }
 
     Account.Id accountId = resource.getUser().getAccountId();
     Map<ExternalId.Key, ExternalId> externalIdMap =
-        dbProvider
-            .get()
-            .accountExternalIds()
-            .byAccount(resource.getUser().getAccountId())
-            .toList()
+        externalIds
+            .byAccount(dbProvider.get(), resource.getUser().getAccountId())
             .stream()
-            .map(ExternalId::from)
             .collect(toMap(i -> i.key(), i -> i));
 
     List<ExternalId> toDelete = new ArrayList<>();
     ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
-    for (String externalIdStr : externalIds) {
+    for (String externalIdStr : extIds) {
       ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
 
       if (id == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
deleted file mode 100644
index c937935..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
+++ /dev/null
@@ -1,105 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Class to read external IDs from NoteDb.
- *
- * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
- * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
- * is a git config file that contains an external ID. It has exactly one externalId subsection with
- * an accountId and optionally email and password:
- *
- * <pre>
- * [externalId "username:jdoe"]
- *   accountId = 1003407
- *   email = jdoe@example.com
- *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
- * </pre>
- */
-@Singleton
-public class ExternalIds {
-  public static final int MAX_NOTE_SZ = 1 << 19;
-
-  public static ObjectId readRevision(Repository repo) throws IOException {
-    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
-    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-  }
-
-  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
-    if (!rev.equals(ObjectId.zeroId())) {
-      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
-    }
-    return NoteMap.newEmptyMap();
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  public ExternalIds(GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  public ObjectId readRevision() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return readRevision(repo);
-    }
-  }
-
-  /** Reads and returns the specified external ID. */
-  @Nullable
-  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev = readRevision(repo);
-      if (rev.equals(ObjectId.zeroId())) {
-        return null;
-      }
-
-      return parse(key, rw, rev);
-    }
-  }
-
-  private ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    NoteMap noteMap = readNoteMap(rw, rev);
-    ObjectId noteId = key.sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    byte[] raw =
-        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    return ExternalId.parse(noteId.name(), raw);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
index 6ea911f..12de82c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
@@ -24,11 +24,14 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -36,26 +39,30 @@
 @Singleton
 public class GetExternalIds implements RestReadView<AccountResource> {
   private final Provider<ReviewDb> db;
+  private final ExternalIds externalIds;
   private final Provider<CurrentUser> self;
   private final AuthConfig authConfig;
 
   @Inject
-  GetExternalIds(Provider<ReviewDb> db, Provider<CurrentUser> self, AuthConfig authConfig) {
+  GetExternalIds(
+      Provider<ReviewDb> db,
+      ExternalIds externalIds,
+      Provider<CurrentUser> self,
+      AuthConfig authConfig) {
     this.db = db;
+    this.externalIds = externalIds;
     this.self = self;
     this.authConfig = authConfig;
   }
 
   @Override
   public List<AccountExternalIdInfo> apply(AccountResource resource)
-      throws RestApiException, OrmException {
+      throws RestApiException, IOException, OrmException {
     if (self.get() != resource.getUser()) {
       throw new AuthException("not allowed to get external IDs");
     }
 
-    Collection<ExternalId> ids =
-        ExternalId.from(
-            db.get().accountExternalIds().byAccount(resource.getUser().getAccountId()).toList());
+    Collection<ExternalId> ids = externalIds.byAccount(db.get(), resource.getUser().getAccountId());
     if (ids.isEmpty()) {
       return ImmutableList.of();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 7791a2e..e7ff314 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
index 435671f..d8451bf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -26,6 +26,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutHttpPassword.Input;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -55,6 +58,7 @@
   private final Provider<CurrentUser> self;
   private final Provider<ReviewDb> dbProvider;
   private final AccountCache accountCache;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdate;
 
   @Inject
@@ -62,10 +66,12 @@
       Provider<CurrentUser> self,
       Provider<ReviewDb> dbProvider,
       AccountCache accountCache,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdate) {
     this.self = self;
     this.dbProvider = dbProvider;
     this.accountCache = accountCache;
+    this.externalIds = externalIds;
     this.externalIdsUpdate = externalIdsUpdate;
   }
 
@@ -109,13 +115,8 @@
     }
 
     ExternalId extId =
-        ExternalId.from(
-            dbProvider
-                .get()
-                .accountExternalIds()
-                .get(
-                    ExternalId.Key.create(SCHEME_USERNAME, user.getUserName())
-                        .asAccountExternalIdKey()));
+        externalIds.get(
+            dbProvider.get(), ExternalId.Key.create(SCHEME_USERNAME, user.getUserName()));
     if (extId == null) {
       throw new ResourceNotFoundException();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
new file mode 100644
index 0000000..8e7a12e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class DisabledExternalIdCache implements ExternalIdCache {
+  public static Module module() {
+    return new AbstractModule() {
+
+      @Override
+      protected void configure() {
+        bind(ExternalIdCache.class).to(DisabledExternalIdCache.class);
+      }
+    };
+  }
+
+  @Override
+  public void onCreate(ObjectId newNotesRev, Iterable<ExternalId> extId) {}
+
+  @Override
+  public void onUpdate(ObjectId newNotesRev, Iterable<ExternalId> extId) {}
+
+  @Override
+  public void onReplace(
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Iterable<ExternalId> toRemove,
+      Iterable<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Iterable<ExternalId.Key> toRemove,
+      Iterable<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId newNotesRev, Iterable<ExternalId.Key> toRemove, Iterable<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplace(
+      ObjectId newNotesRev, Iterable<ExternalId> toRemove, Iterable<ExternalId> toAdd) {}
+
+  @Override
+  public void onRemove(ObjectId newNotesRev, Iterable<ExternalId> extId) {}
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId newNotesRev, Account.Id accountId, Iterable<ExternalId.Key> extIdKeys) {}
+
+  @Override
+  public void onRemoveByKeys(ObjectId newNotesRev, Iterable<ExternalId.Key> extIdKeys) {}
+
+  @Override
+  public Set<ExternalId> byAccount(Account.Id accountId) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Set<ExternalId> byEmail(String email) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
similarity index 91%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
index d057f56..30e5a4b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -12,8 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account;
+package com.google.gerrit.server.account.externalids;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toSet;
 
@@ -27,6 +28,7 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.server.account.HashedPassword;
 import java.io.Serializable;
 import java.util.Collection;
 import java.util.Set;
@@ -71,7 +73,7 @@
     private static final long serialVersionUID = 1L;
 
     public static Key create(@Nullable String scheme, String id) {
-      return new AutoValue_ExternalId_Key(scheme, id);
+      return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id);
     }
 
     public static ExternalId.Key from(AccountExternalId.Key externalIdKey) {
@@ -158,7 +160,8 @@
 
   public static ExternalId create(
       Key key, Account.Id accountId, @Nullable String email, @Nullable String hashedPassword) {
-    return new AutoValue_ExternalId(key, accountId, email, hashedPassword);
+    return new AutoValue_ExternalId(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword));
   }
 
   public static ExternalId createWithPassword(
@@ -174,16 +177,16 @@
   }
 
   public static ExternalId createWithEmail(
-      String scheme, String id, Account.Id accountId, String email) {
+      String scheme, String id, Account.Id accountId, @Nullable String email) {
     return createWithEmail(Key.create(scheme, id), accountId, email);
   }
 
-  public static ExternalId createWithEmail(Key key, Account.Id accountId, String email) {
-    return new AutoValue_ExternalId(key, accountId, email, null);
+  public static ExternalId createWithEmail(Key key, Account.Id accountId, @Nullable String email) {
+    return new AutoValue_ExternalId(key, accountId, Strings.emptyToNull(email), null);
   }
 
   public static ExternalId createEmail(Account.Id accountId, String email) {
-    return createWithEmail(SCHEME_MAILTO, email, accountId, email);
+    return createWithEmail(SCHEME_MAILTO, email, accountId, checkNotNull(email));
   }
 
   /**
@@ -243,7 +246,11 @@
               accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
     }
 
-    return new AutoValue_ExternalId(externalIdKey, new Account.Id(accountId), email, password);
+    return new AutoValue_ExternalId(
+        externalIdKey,
+        new Account.Id(accountId),
+        Strings.emptyToNull(email),
+        Strings.emptyToNull(password));
   }
 
   private static ConfigInvalidException invalidConfig(String noteId, String message) {
@@ -259,8 +266,8 @@
     return new AutoValue_ExternalId(
         ExternalId.Key.parse(externalId.getExternalId()),
         externalId.getAccountId(),
-        externalId.getEmailAddress(),
-        externalId.getPassword());
+        Strings.emptyToNull(externalId.getEmailAddress()),
+        Strings.emptyToNull(externalId.getPassword()));
   }
 
   public static Set<ExternalId> from(Collection<AccountExternalId> externalIds) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
new file mode 100644
index 0000000..ac2c279
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.gerrit.reviewdb.client.Account;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Caches external IDs of all accounts */
+interface ExternalIdCache {
+  void onCreate(ObjectId newNotesRev, Iterable<ExternalId> extId) throws IOException;
+
+  void onUpdate(ObjectId newNotesRev, Iterable<ExternalId> extId) throws IOException;
+
+  void onReplace(
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Iterable<ExternalId> toRemove,
+      Iterable<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplaceByKeys(
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Iterable<ExternalId.Key> toRemove,
+      Iterable<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplaceByKeys(
+      ObjectId newNotesRev, Iterable<ExternalId.Key> toRemove, Iterable<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplace(ObjectId newNotesRev, Iterable<ExternalId> toRemove, Iterable<ExternalId> toAdd)
+      throws IOException;
+
+  void onRemove(ObjectId newNotesRev, Iterable<ExternalId> extId) throws IOException;
+
+  void onRemoveByKeys(
+      ObjectId newNotesRev, Account.Id accountId, Iterable<ExternalId.Key> extIdKeys)
+      throws IOException;
+
+  void onRemoveByKeys(ObjectId newNotesRev, Iterable<ExternalId.Key> extIdKeys) throws IOException;
+
+  Set<ExternalId> byAccount(Account.Id accountId) throws IOException;
+
+  Set<ExternalId> byEmail(String email) throws IOException;
+
+  default void onCreate(ObjectId newNotesRev, ExternalId extId) throws IOException {
+    onCreate(newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onRemove(ObjectId newNotesRev, ExternalId extId) throws IOException {
+    onRemove(newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onRemoveByKey(ObjectId newNotesRev, Account.Id accountId, ExternalId.Key extIdKey)
+      throws IOException {
+    onRemoveByKeys(newNotesRev, accountId, Collections.singleton(extIdKey));
+  }
+
+  default void onUpdate(ObjectId newNotesRev, ExternalId updatedExtId) throws IOException {
+    onUpdate(newNotesRev, Collections.singleton(updatedExtId));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
new file mode 100644
index 0000000..7fb61fc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -0,0 +1,280 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. */
+@Singleton
+class ExternalIdCacheImpl implements ExternalIdCache {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdCacheImpl.class);
+
+  public static final String CACHE_NAME = "external_ids_map";
+
+  private final LoadingCache<ObjectId, ImmutableSetMultimap<Account.Id, ExternalId>>
+      extIdsByAccount;
+  private final ExternalIdReader externalIdReader;
+  private final Lock lock;
+
+  @Inject
+  ExternalIdCacheImpl(
+      @Named(CACHE_NAME)
+          LoadingCache<ObjectId, ImmutableSetMultimap<Account.Id, ExternalId>> extIdsByAccount,
+      ExternalIdReader externalIdReader) {
+    this.extIdsByAccount = extIdsByAccount;
+    this.externalIdReader = externalIdReader;
+    this.lock = new ReentrantLock(true /* fair */);
+  }
+
+  @Override
+  public void onCreate(ObjectId newNotesRev, Iterable<ExternalId> extIds) throws IOException {
+    updateCache(
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onRemove(ObjectId newNotesRev, Iterable<ExternalId> extIds) throws IOException {
+    updateCache(
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            m.remove(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onRemoveByKeys(
+      ObjectId newNotesRev, Account.Id accountId, Iterable<ExternalId.Key> extIdKeys)
+      throws IOException {
+    updateCache(
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : m.get(accountId)) {
+            for (ExternalId.Key extIdKey : extIdKeys) {
+              if (extIdKey.equals(extId.key())) {
+                m.remove(accountId, extId);
+                break;
+              }
+            }
+          }
+        });
+  }
+
+  @Override
+  public void onRemoveByKeys(ObjectId newNotesRev, Iterable<ExternalId.Key> extIdKeys)
+      throws IOException {
+    updateCache(
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : m.values()) {
+            for (ExternalId.Key extIdKey : extIdKeys) {
+              if (extIdKey.equals(extId.key())) {
+                m.remove(extId.accountId(), extId);
+                break;
+              }
+            }
+          }
+        });
+  }
+
+  @Override
+  public void onUpdate(ObjectId newNotesRev, Iterable<ExternalId> updatedExtIds)
+      throws IOException {
+    updateCache(
+        newNotesRev,
+        m -> {
+          for (ExternalId updatedExtId : updatedExtIds) {
+            for (ExternalId extId : m.get(updatedExtId.accountId())) {
+              if (updatedExtId.key().equals(extId.key())) {
+                m.remove(updatedExtId.accountId(), extId);
+                break;
+              }
+            }
+            m.put(updatedExtId.accountId(), updatedExtId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Iterable<ExternalId> toRemove,
+      Iterable<ExternalId> toAdd)
+      throws IOException {
+    ExternalIdsUpdate.checkSameAccount(Iterables.concat(toRemove, toAdd), accountId);
+
+    updateCache(
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Iterable<ExternalId.Key> toRemove,
+      Iterable<ExternalId> toAdd)
+      throws IOException {
+    ExternalIdsUpdate.checkSameAccount(toAdd, accountId);
+
+    updateCache(
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : m.get(accountId)) {
+            for (ExternalId.Key extIdKey : toRemove) {
+              if (extIdKey.equals(extId.key())) {
+                m.remove(accountId, extId);
+              }
+            }
+          }
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplaceByKeys(
+      ObjectId newNotesRev, Iterable<ExternalId.Key> toRemove, Iterable<ExternalId> toAdd)
+      throws IOException {
+    updateCache(
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : m.values()) {
+            for (ExternalId.Key extIdKey : toRemove) {
+              if (extIdKey.equals(extId.key())) {
+                m.remove(extId.accountId(), extId);
+              }
+            }
+          }
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId newNotesRev, Iterable<ExternalId> toRemove, Iterable<ExternalId> toAdd)
+      throws IOException {
+    updateCache(
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
+    try {
+      return extIdsByAccount.get(externalIdReader.readRevision()).get(accountId);
+    } catch (ExecutionException e) {
+      log.warn("Cannot list external ids by account", e);
+      return Collections.emptySet();
+    }
+  }
+
+  @Override
+  public Set<ExternalId> byEmail(String email) throws IOException {
+    try {
+      return extIdsByAccount
+          .get(externalIdReader.readRevision())
+          .values()
+          .stream()
+          .filter(e -> email.equals(e.email()))
+          .collect(toSet());
+    } catch (ExecutionException e) {
+      log.warn("Cannot list external ids by email", e);
+      return Collections.emptySet();
+    }
+  }
+
+  private void updateCache(ObjectId newNotesRev, Consumer<Multimap<Account.Id, ExternalId>> update)
+      throws IOException {
+    lock.lock();
+    try {
+      ListMultimap<Account.Id, ExternalId> m =
+          MultimapBuilder.hashKeys()
+              .arrayListValues()
+              .build(extIdsByAccount.get(externalIdReader.readRevision()));
+      update.accept(m);
+      extIdsByAccount.put(newNotesRev, ImmutableSetMultimap.copyOf(m));
+    } catch (ExecutionException e) {
+      log.warn("Cannot update external IDs", e);
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  static class Loader extends CacheLoader<ObjectId, ImmutableSetMultimap<Account.Id, ExternalId>> {
+    private final ExternalIdReader externalIdReader;
+
+    @Inject
+    Loader(ExternalIdReader externalIdReader) {
+      this.externalIdReader = externalIdReader;
+    }
+
+    @Override
+    public ImmutableSetMultimap<Account.Id, ExternalId> load(ObjectId notesRev) throws Exception {
+      Multimap<Account.Id, ExternalId> extIdsByAccount =
+          MultimapBuilder.hashKeys().arrayListValues().build();
+      for (ExternalId extId : externalIdReader.all(notesRev)) {
+        extIdsByAccount.put(extId.accountId(), extId);
+      }
+      return ImmutableSetMultimap.copyOf(extIdsByAccount);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
new file mode 100644
index 0000000..3486b4e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.ExternalIdCacheImpl.Loader;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.inject.TypeLiteral;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class ExternalIdModule extends CacheModule {
+  @Override
+  protected void configure() {
+    cache(
+            ExternalIdCacheImpl.CACHE_NAME,
+            ObjectId.class,
+            new TypeLiteral<ImmutableSetMultimap<Account.Id, ExternalId>>() {})
+        // The cached data is potentially pretty large and we are always only interested
+        // in the latest value, hence the maximum cache weight is set to 1.
+        // This can lead to extra cache loads in case of the following race:
+        // 1. thread 1 reads the notes ref at revision A
+        // 2. thread 2 updates the notes ref to revision B and stores the derived value
+        //    for B in the cache
+        // 3. thread 1 attempts to read the data for revision A from the cache, and misses
+        // 4. later threads attempt to read at B
+        // In this race unneeded reloads are done in step 3 (reload from revision A) and
+        // step 4 (reload from revision B, because the value for revision B was lost when the
+        // reload from revision A was done, since the cache can hold only one entry).
+        // These reloads could be avoided by increasing the cache size to 2. However the race
+        // window between reading the ref and looking it up in the cache is small so that
+        // it's rare that this race happens. Therefore it's not worth to double the memory
+        // usage of this cache, just to avoid this.
+        .maximumWeight(1)
+        .loader(Loader.class);
+
+    bind(ExternalIdCacheImpl.class);
+    bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
new file mode 100644
index 0000000..7b607dc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class to read external IDs from ReviewDb or NoteDb.
+ *
+ * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
+ * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
+ * is a git config file that contains an external ID. It has exactly one externalId subsection with
+ * an accountId and optionally email and password:
+ *
+ * <pre>
+ * [externalId "username:jdoe"]
+ *   accountId = 1003407
+ *   email = jdoe@example.com
+ *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+ * </pre>
+ */
+@Singleton
+public class ExternalIdReader {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdReader.class);
+
+  public static final int MAX_NOTE_SZ = 1 << 19;
+
+  public static ObjectId readRevision(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
+  }
+
+  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
+    if (!rev.equals(ObjectId.zeroId())) {
+      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
+    }
+    return NoteMap.newEmptyMap();
+  }
+
+  private final boolean readFromGit;
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  ExternalIdReader(
+      @GerritServerConfig Config cfg, GitRepositoryManager repoManager, AllUsersName allUsersName) {
+    this.readFromGit = cfg.getBoolean("user", null, "readExternalIdsFromGit", false);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  boolean readFromGit() {
+    return readFromGit;
+  }
+
+  ObjectId readRevision() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return readRevision(repo);
+    }
+  }
+
+  /** Reads and returns all external IDs. */
+  Set<ExternalId> all(ReviewDb db) throws IOException, OrmException {
+    if (readFromGit) {
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        return all(repo, readRevision(repo));
+      }
+    }
+
+    return ExternalId.from(db.accountExternalIds().all().toList());
+  }
+
+  /**
+   * Reads and returns all external IDs from the specified revision of the refs/meta/external-ids
+   * branch.
+   */
+  Set<ExternalId> all(ObjectId rev) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return all(repo, rev);
+    }
+  }
+
+  /** Reads and returns all external IDs. */
+  private static Set<ExternalId> all(Repository repo, ObjectId rev) throws IOException {
+    if (rev.equals(ObjectId.zeroId())) {
+      return ImmutableSet.of();
+    }
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      NoteMap noteMap = readNoteMap(rw, rev);
+      Set<ExternalId> extIds = new HashSet<>();
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader().open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+        try {
+          extIds.add(ExternalId.parse(note.getName(), raw));
+        } catch (ConfigInvalidException e) {
+          log.error(String.format("Ignoring invalid external ID note %s", note.getName()), e);
+        }
+      }
+      return extIds;
+    }
+  }
+
+  /** Reads and returns the specified external ID. */
+  @Nullable
+  ExternalId get(ReviewDb db, ExternalId.Key key)
+      throws IOException, ConfigInvalidException, OrmException {
+    if (readFromGit) {
+      try (Repository repo = repoManager.openRepository(allUsersName);
+          RevWalk rw = new RevWalk(repo)) {
+        ObjectId rev = readRevision(repo);
+        if (rev.equals(ObjectId.zeroId())) {
+          return null;
+        }
+
+        return parse(key, rw, rev);
+      }
+    }
+    return ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
+  }
+
+  /** Reads and returns the specified external ID from the given revision. */
+  @Nullable
+  ExternalId get(ExternalId.Key key, ObjectId rev) throws IOException, ConfigInvalidException {
+    if (rev.equals(ObjectId.zeroId())) {
+      return null;
+    }
+
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      return parse(key, rw, rev);
+    }
+  }
+
+  private static ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    NoteMap noteMap = readNoteMap(rw, rev);
+    ObjectId noteId = key.sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    byte[] raw =
+        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    return ExternalId.parse(noteId.name(), raw);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
new file mode 100644
index 0000000..b77fed8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Class to access external IDs.
+ *
+ * <p>The external IDs are either read from NoteDb or retrieved from the cache.
+ */
+@Singleton
+public class ExternalIds {
+  private final ExternalIdReader externalIdReader;
+  private final ExternalIdCache externalIdCache;
+
+  @Inject
+  public ExternalIds(ExternalIdReader externalIdReader, ExternalIdCache externalIdCache) {
+    this.externalIdReader = externalIdReader;
+    this.externalIdCache = externalIdCache;
+  }
+
+  /** Returns all external IDs. */
+  public Set<ExternalId> all(ReviewDb db) throws IOException, OrmException {
+    return externalIdReader.all(db);
+  }
+
+  /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
+  public Set<ExternalId> all(ObjectId rev) throws IOException {
+    return externalIdReader.all(rev);
+  }
+
+  /** Returns the specified external ID. */
+  @Nullable
+  public ExternalId get(ReviewDb db, ExternalId.Key key)
+      throws IOException, ConfigInvalidException, OrmException {
+    return externalIdReader.get(db, key);
+  }
+
+  /** Returns the specified external ID from the given revision. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    return externalIdReader.get(key, rev);
+  }
+
+  /** Returns the external IDs of the specified account. */
+  public Set<ExternalId> byAccount(ReviewDb db, Account.Id accountId)
+      throws IOException, OrmException {
+    if (externalIdReader.readFromGit()) {
+      return externalIdCache.byAccount(accountId);
+    }
+
+    return ExternalId.from(db.accountExternalIds().byAccount(accountId).toList());
+  }
+
+  /** Returns the external IDs of the specified account that have the given scheme. */
+  public Set<ExternalId> byAccount(ReviewDb db, Account.Id accountId, String scheme)
+      throws IOException, OrmException {
+    return byAccount(db, accountId).stream().filter(e -> e.key().isScheme(scheme)).collect(toSet());
+  }
+
+  public Set<ExternalId> byEmail(ReviewDb db, String email) throws IOException, OrmException {
+    if (externalIdReader.readFromGit()) {
+      return externalIdCache.byEmail(email);
+    }
+
+    return ExternalId.from(db.accountExternalIds().byEmailAddress(email).toList());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
similarity index 85%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
index 531e562..79e7bb6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
@@ -12,9 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account;
+package com.google.gerrit.server.account.externalids;
 
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
+import static com.google.gerrit.server.account.externalids.ExternalId.toAccountExternalIds;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -46,6 +46,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final PersonIdent serverIdent;
+  private final ExternalIdCache externalIdCache;
   private final Set<ExternalId> toAdd = new HashSet<>();
   private final Set<ExternalId> toDelete = new HashSet<>();
 
@@ -53,10 +54,12 @@
   public ExternalIdsBatchUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverIdent) {
+      @GerritPersonIdent PersonIdent serverIdent,
+      ExternalIdCache externalIdCache) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.serverIdent = serverIdent;
+    this.externalIdCache = externalIdCache;
   }
 
   /**
@@ -94,9 +97,9 @@
     try (Repository repo = repoManager.openRepository(allUsersName);
         RevWalk rw = new RevWalk(repo);
         ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId rev = ExternalIds.readRevision(repo);
+      ObjectId rev = ExternalIdReader.readRevision(repo);
 
-      NoteMap noteMap = ExternalIds.readNoteMap(rw, rev);
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
 
       for (ExternalId extId : toDelete) {
         ExternalIdsUpdate.remove(rw, noteMap, extId);
@@ -106,8 +109,10 @@
         ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
       }
 
-      ExternalIdsUpdate.commit(
-          repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+      ObjectId newRev =
+          ExternalIdsUpdate.commit(
+              repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+      externalIdCache.onReplace(newRev, toDelete, toAdd);
     }
 
     toAdd.clear();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
similarity index 70%
rename from gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
index a596a8e..561d05e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
@@ -12,15 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account;
+package com.google.gerrit.server.account.externalids;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.account.ExternalId.Key.toAccountExternalIdKeys;
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
-import static com.google.gerrit.server.account.ExternalIds.MAX_NOTE_SZ;
-import static com.google.gerrit.server.account.ExternalIds.readNoteMap;
-import static com.google.gerrit.server.account.ExternalIds.readRevision;
+import static com.google.gerrit.server.account.externalids.ExternalId.Key.toAccountExternalIdKeys;
+import static com.google.gerrit.server.account.externalids.ExternalId.toAccountExternalIds;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.MAX_NOTE_SZ;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.readNoteMap;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.readRevision;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -98,21 +98,27 @@
   public static class Server {
     private final GitRepositoryManager repoManager;
     private final AllUsersName allUsersName;
+    private final ExternalIds externalIds;
+    private final ExternalIdCache externalIdCache;
     private final Provider<PersonIdent> serverIdent;
 
     @Inject
     public Server(
         GitRepositoryManager repoManager,
         AllUsersName allUsersName,
+        ExternalIds externalIds,
+        ExternalIdCache externalIdCache,
         @GerritPersonIdent Provider<PersonIdent> serverIdent) {
       this.repoManager = repoManager;
       this.allUsersName = allUsersName;
+      this.externalIds = externalIds;
+      this.externalIdCache = externalIdCache;
       this.serverIdent = serverIdent;
     }
 
     public ExternalIdsUpdate create() {
       PersonIdent i = serverIdent.get();
-      return new ExternalIdsUpdate(repoManager, allUsersName, i, i);
+      return new ExternalIdsUpdate(repoManager, allUsersName, externalIds, externalIdCache, i, i);
     }
   }
 
@@ -126,6 +132,8 @@
   public static class User {
     private final GitRepositoryManager repoManager;
     private final AllUsersName allUsersName;
+    private final ExternalIds externalIds;
+    private final ExternalIdCache externalIdCache;
     private final Provider<PersonIdent> serverIdent;
     private final Provider<IdentifiedUser> identifiedUser;
 
@@ -133,10 +141,14 @@
     public User(
         GitRepositoryManager repoManager,
         AllUsersName allUsersName,
+        ExternalIds externalIds,
+        ExternalIdCache externalIdCache,
         @GerritPersonIdent Provider<PersonIdent> serverIdent,
         Provider<IdentifiedUser> identifiedUser) {
       this.repoManager = repoManager;
       this.allUsersName = allUsersName;
+      this.externalIds = externalIds;
+      this.externalIdCache = externalIdCache;
       this.serverIdent = serverIdent;
       this.identifiedUser = identifiedUser;
     }
@@ -144,7 +156,12 @@
     public ExternalIdsUpdate create() {
       PersonIdent i = serverIdent.get();
       return new ExternalIdsUpdate(
-          repoManager, allUsersName, createPersonIdent(i, identifiedUser.get()), i);
+          repoManager,
+          allUsersName,
+          externalIds,
+          externalIdCache,
+          createPersonIdent(i, identifiedUser.get()),
+          i);
     }
 
     private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
@@ -153,8 +170,8 @@
   }
 
   @VisibleForTesting
-  public static RetryerBuilder<Void> retryerBuilder() {
-    return RetryerBuilder.<Void>newBuilder()
+  public static RetryerBuilder<ObjectId> retryerBuilder() {
+    return RetryerBuilder.<ObjectId>newBuilder()
         .retryIfException(e -> e instanceof LockFailureException)
         .withWaitStrategy(
             WaitStrategies.join(
@@ -163,34 +180,50 @@
         .withStopStrategy(StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS));
   }
 
-  private static final Retryer<Void> RETRYER = retryerBuilder().build();
+  private static final Retryer<ObjectId> RETRYER = retryerBuilder().build();
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
+  private final ExternalIdCache externalIdCache;
   private final PersonIdent committerIdent;
   private final PersonIdent authorIdent;
   private final Runnable afterReadRevision;
-  private final Retryer<Void> retryer;
+  private final Retryer<ObjectId> retryer;
 
   private ExternalIdsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
+      ExternalIds externalIds,
+      ExternalIdCache externalIdCache,
       PersonIdent committerIdent,
       PersonIdent authorIdent) {
-    this(repoManager, allUsersName, committerIdent, authorIdent, Runnables.doNothing(), RETRYER);
+    this(
+        repoManager,
+        allUsersName,
+        externalIds,
+        externalIdCache,
+        committerIdent,
+        authorIdent,
+        Runnables.doNothing(),
+        RETRYER);
   }
 
   @VisibleForTesting
   public ExternalIdsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
+      ExternalIds externalIds,
+      ExternalIdCache externalIdCache,
       PersonIdent committerIdent,
       PersonIdent authorIdent,
       Runnable afterReadRevision,
-      Retryer<Void> retryer) {
+      Retryer<ObjectId> retryer) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
     this.allUsersName = checkNotNull(allUsersName, "allUsersName");
     this.committerIdent = checkNotNull(committerIdent, "committerIdent");
+    this.externalIds = checkNotNull(externalIds, "externalIds");
+    this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
     this.authorIdent = checkNotNull(authorIdent, "authorIdent");
     this.afterReadRevision = checkNotNull(afterReadRevision, "afterReadRevision");
     this.retryer = checkNotNull(retryer, "retryer");
@@ -216,12 +249,14 @@
       throws IOException, ConfigInvalidException, OrmException {
     db.accountExternalIds().insert(toAccountExternalIds(extIds));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId extId : extIds) {
-            insert(o.rw(), o.ins(), o.noteMap(), extId);
-          }
-        });
+    ObjectId newRev =
+        updateNoteMap(
+            o -> {
+              for (ExternalId extId : extIds) {
+                insert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onCreate(newRev, extIds);
   }
 
   /**
@@ -243,19 +278,21 @@
       throws IOException, ConfigInvalidException, OrmException {
     db.accountExternalIds().upsert(toAccountExternalIds(extIds));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId extId : extIds) {
-            upsert(o.rw(), o.ins(), o.noteMap(), extId);
-          }
-        });
+    ObjectId newRev =
+        updateNoteMap(
+            o -> {
+              for (ExternalId extId : extIds) {
+                upsert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onUpdate(newRev, extIds);
   }
 
   /**
    * Deletes an external ID.
    *
-   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key, but otherwise doesn't match the specified external ID.
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
    */
   public void delete(ReviewDb db, ExternalId extId)
       throws IOException, ConfigInvalidException, OrmException {
@@ -265,27 +302,29 @@
   /**
    * Deletes external IDs.
    *
-   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key as any of the external IDs that should be deleted, but otherwise doesn't
-   * match the that external ID.
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key as any of the external IDs that should be deleted, but otherwise doesn't match the that
+   *     external ID.
    */
   public void delete(ReviewDb db, Collection<ExternalId> extIds)
       throws IOException, ConfigInvalidException, OrmException {
     db.accountExternalIds().delete(toAccountExternalIds(extIds));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId extId : extIds) {
-            remove(o.rw(), o.noteMap(), extId);
-          }
-        });
+    ObjectId newRev =
+        updateNoteMap(
+            o -> {
+              for (ExternalId extId : extIds) {
+                remove(o.rw(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onRemove(newRev, extIds);
   }
 
   /**
    * Delete an external ID by key.
    *
-   * <p>The external ID is only deleted if it belongs to the specified account. If it belongs to
-   * another account the deletion fails with {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if the external ID does not belong to the specified
+   *     account.
    */
   public void delete(ReviewDb db, Account.Id accountId, ExternalId.Key extIdKey)
       throws IOException, ConfigInvalidException, OrmException {
@@ -295,25 +334,46 @@
   /**
    * Delete external IDs by external ID key.
    *
-   * <p>The external IDs are only deleted if they belongs to the specified account. If any of the
-   * external IDs belongs to another account the deletion fails with {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if any of the external IDs does not belong to the
+   *     specified account.
    */
   public void delete(ReviewDb db, Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
       throws IOException, ConfigInvalidException, OrmException {
     db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(extIdKeys));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId.Key extIdKey : extIdKeys) {
-            remove(o.rw(), o.noteMap(), accountId, extIdKey);
-          }
-        });
+    ObjectId newRev =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : extIdKeys) {
+                remove(o.rw(), o.noteMap(), extIdKey, accountId);
+              }
+            });
+    externalIdCache.onRemoveByKeys(newRev, accountId, extIdKeys);
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * <p>The external IDs are deleted regardless of which account they belong to.
+   */
+  public void deleteByKeys(ReviewDb db, Collection<ExternalId.Key> extIdKeys)
+      throws IOException, ConfigInvalidException, OrmException {
+    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(extIdKeys));
+
+    ObjectId newRev =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : extIdKeys) {
+                remove(o.rw(), o.noteMap(), extIdKey, null);
+              }
+            });
+    externalIdCache.onRemoveByKeys(newRev, extIdKeys);
   }
 
   /** Deletes all external IDs of the specified account. */
   public void deleteAll(ReviewDb db, Account.Id accountId)
       throws IOException, ConfigInvalidException, OrmException {
-    delete(db, ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()));
+    delete(db, externalIds.byAccount(db, accountId));
   }
 
   /**
@@ -324,8 +384,8 @@
    * be added, the old external ID with that key is deleted first and then the new external ID is
    * added (so the external ID for that key is replaced).
    *
-   * <p>If any of the specified external IDs belongs to another account the replacement fails with
-   * {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if any of the specified external IDs does not belong to
+   *     the specified account.
    */
   public void replace(
       ReviewDb db,
@@ -338,23 +398,55 @@
     db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(toDelete));
     db.accountExternalIds().insert(toAccountExternalIds(toAdd));
 
-    updateNoteMap(
-        o -> {
-          for (ExternalId.Key extIdKey : toDelete) {
-            remove(o.rw(), o.noteMap(), accountId, extIdKey);
-          }
+    ObjectId newRev =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : toDelete) {
+                remove(o.rw(), o.noteMap(), extIdKey, accountId);
+              }
 
-          for (ExternalId extId : toAdd) {
-            insert(o.rw(), o.ins(), o.noteMap(), extId);
-          }
-        });
+              for (ExternalId extId : toAdd) {
+                insert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onReplaceByKeys(newRev, accountId, toDelete, toAdd);
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * <p>The external IDs are replaced regardless of which account they belong to.
+   */
+  public void replaceByKeys(
+      ReviewDb db, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(toDelete));
+    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
+
+    ObjectId newRev =
+        updateNoteMap(
+            o -> {
+              for (ExternalId.Key extIdKey : toDelete) {
+                remove(o.rw(), o.noteMap(), extIdKey, null);
+              }
+
+              for (ExternalId extId : toAdd) {
+                insert(o.rw(), o.ins(), o.noteMap(), extId);
+              }
+            });
+    externalIdCache.onReplaceByKeys(newRev, toDelete, toAdd);
   }
 
   /**
    * Replaces an external ID.
    *
-   * <p>If the specified external IDs belongs to different accounts the replacement fails with
-   * {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
    */
   public void replace(ReviewDb db, ExternalId toDelete, ExternalId toAdd)
       throws IOException, ConfigInvalidException, OrmException {
@@ -369,8 +461,8 @@
    * added, the old external ID with that key is deleted first and then the new external ID is added
    * (so the external ID for that key is replaced).
    *
-   * <p>If the specified external IDs belong to different accounts the replacement fails with {@link
-   * IllegalStateException}.
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
    */
   public void replace(ReviewDb db, Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
       throws IOException, ConfigInvalidException, OrmException {
@@ -457,8 +549,8 @@
   /**
    * Removes an external ID from the note map.
    *
-   * <p>The removal fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key, but otherwise doesn't match the specified external ID.
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
    */
   public static void remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
@@ -481,11 +573,11 @@
   /**
    * Removes an external ID from the note map by external ID key.
    *
-   * <p>The external ID is only deleted if it belongs to the specified account. If the external IDs
-   * belongs to another account the deletion fails with {@link IllegalStateException}.
+   * @throws IllegalStateException is thrown if an expected account ID is provided and an external
+   *     ID with the specified key exists, but belongs to another account.
    */
   private static void remove(
-      RevWalk rw, NoteMap noteMap, Account.Id accountId, ExternalId.Key extIdKey)
+      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extIdKey.sha1();
     if (!noteMap.contains(noteId)) {
@@ -495,22 +587,24 @@
     byte[] raw =
         rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
     ExternalId extId = ExternalId.parse(noteId.name(), raw);
-    checkState(
-        accountId.equals(extId.accountId()),
-        "external id %s should be removed for account %s,"
-            + " but external id belongs to account %s",
-        extIdKey.get(),
-        accountId.get(),
-        extId.accountId().get());
+    if (expectedAccountId != null) {
+      checkState(
+          expectedAccountId.equals(extId.accountId()),
+          "external id %s should be removed for account %s,"
+              + " but external id belongs to account %s",
+          extIdKey.get(),
+          expectedAccountId.get(),
+          extId.accountId().get());
+    }
     noteMap.remove(noteId);
   }
 
-  private void updateNoteMap(MyConsumer<OpenRepo> update)
+  private ObjectId updateNoteMap(MyConsumer<OpenRepo> update)
       throws IOException, ConfigInvalidException, OrmException {
     try (Repository repo = repoManager.openRepository(allUsersName);
         RevWalk rw = new RevWalk(repo);
         ObjectInserter ins = repo.newObjectInserter()) {
-      retryer.call(new TryNoteMapUpdate(repo, rw, ins, update));
+      return retryer.call(new TryNoteMapUpdate(repo, rw, ins, update));
     } catch (ExecutionException | RetryException e) {
       if (e.getCause() != null) {
         Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
@@ -521,14 +615,14 @@
     }
   }
 
-  private void commit(
+  private ObjectId commit(
       Repository repo, RevWalk rw, ObjectInserter ins, ObjectId rev, NoteMap noteMap)
       throws IOException {
-    commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, committerIdent, authorIdent);
+    return commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, committerIdent, authorIdent);
   }
 
   /** Commits updates to the external IDs. */
-  public static void commit(
+  public static ObjectId commit(
       Repository repo,
       RevWalk rw,
       ObjectInserter ins,
@@ -581,12 +675,14 @@
       default:
         throw new IOException("Updating external IDs failed with " + res);
     }
+    return commitId;
   }
 
   private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
     return ins.insert(OBJ_TREE, new byte[] {});
   }
 
+  @FunctionalInterface
   private static interface MyConsumer<T> {
     void accept(T t) throws IOException, ConfigInvalidException, OrmException;
   }
@@ -606,7 +702,7 @@
     abstract NoteMap noteMap();
   }
 
-  private class TryNoteMapUpdate implements Callable<Void> {
+  private class TryNoteMapUpdate implements Callable<ObjectId> {
     private final Repository repo;
     private final RevWalk rw;
     private final ObjectInserter ins;
@@ -621,7 +717,7 @@
     }
 
     @Override
-    public Void call() throws Exception {
+    public ObjectId call() throws Exception {
       ObjectId rev = readRevision(repo);
 
       afterReadRevision.run();
@@ -629,8 +725,7 @@
       NoteMap noteMap = readNoteMap(rw, rev);
       update.accept(OpenRepo.create(repo, rw, ins, noteMap));
 
-      commit(repo, rw, ins, rev, noteMap);
-      return null;
+      return commit(repo, rw, ins, rev, noteMap);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 430b6b7..23c6537 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -485,7 +485,7 @@
   public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
     try {
       return getExternalIds.apply(account);
-    } catch (OrmException e) {
+    } catch (IOException | OrmException e) {
       throw new RestApiException("Cannot get external IDs", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
index 2d90853..2f8dee6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.api.accounts;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import java.util.List;
 
 public interface AccountExternalIdCreator {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 1da1354..815b4f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.server.change.CreateMergePatchSet;
 import com.google.gerrit.server.change.DeleteAssignee;
 import com.google.gerrit.server.change.DeleteChange;
+import com.google.gerrit.server.change.DeletePrivate;
 import com.google.gerrit.server.change.GetAssignee;
 import com.google.gerrit.server.change.GetHashtags;
 import com.google.gerrit.server.change.GetPastAssignees;
@@ -64,6 +65,7 @@
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.PublishDraftPatchSet;
 import com.google.gerrit.server.change.PutAssignee;
+import com.google.gerrit.server.change.PutPrivate;
 import com.google.gerrit.server.change.PutTopic;
 import com.google.gerrit.server.change.Rebase;
 import com.google.gerrit.server.change.Restore;
@@ -122,6 +124,8 @@
   private final Check check;
   private final Index index;
   private final Move move;
+  private final PutPrivate putPrivate;
+  private final DeletePrivate deletePrivate;
 
   @Inject
   ChangeApiImpl(
@@ -157,6 +161,8 @@
       Check check,
       Index index,
       Move move,
+      PutPrivate putPrivate,
+      DeletePrivate deletePrivate,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
@@ -190,6 +196,8 @@
     this.check = check;
     this.index = index;
     this.move = move;
+    this.putPrivate = putPrivate;
+    this.deletePrivate = deletePrivate;
     this.change = change;
   }
 
@@ -271,6 +279,19 @@
   }
 
   @Override
+  public void setPrivate(boolean value) throws RestApiException {
+    try {
+      if (value) {
+        putPrivate.apply(change, null);
+      } else {
+        deletePrivate.apply(change, null);
+      }
+    } catch (UpdateException e) {
+      throw new RestApiException("Cannot change private status", e);
+    }
+  }
+
+  @Override
   public ChangeApi revert() throws RestApiException {
     return revert(new RevertInput());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 15120d2..2c1ee3e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -42,8 +42,8 @@
 import com.google.gerrit.server.group.PutOptions;
 import com.google.gerrit.server.group.PutOwner;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
@@ -73,7 +73,7 @@
   private final GroupResource rsrc;
   private final Index index;
 
-  @AssistedInject
+  @Inject
   GroupApiImpl(
       GetGroup getGroup,
       GetDetail getDetail,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
index 925b647..1595682 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.ChildProjectResource;
 import com.google.gerrit.server.project.GetChildProject;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 
 public class ChildProjectApiImpl implements ChildProjectApi {
   interface Factory {
@@ -30,7 +30,7 @@
   private final GetChildProject getChildProject;
   private final ChildProjectResource rsrc;
 
-  @AssistedInject
+  @Inject
   ChildProjectApiImpl(GetChildProject getChildProject, @Assisted ChildProjectResource rsrc) {
     this.getChildProject = getChildProject;
     this.rsrc = rsrc;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
new file mode 100644
index 0000000..9e17498
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.projects.CommitApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.CherryPickCommit;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+
+public class CommitApiImpl implements CommitApi {
+  public interface Factory {
+    CommitApiImpl create(CommitResource r);
+  }
+
+  private final Changes changes;
+  private final CherryPickCommit cherryPickCommit;
+  private final CommitResource commitResource;
+
+  @Inject
+  CommitApiImpl(
+      Changes changes, CherryPickCommit cherryPickCommit, @Assisted CommitResource commitResource) {
+    this.changes = changes;
+    this.cherryPickCommit = cherryPickCommit;
+    this.commitResource = commitResource;
+  }
+
+  @Override
+  public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
+    try {
+      return changes.id(cherryPickCommit.apply(commitResource, input)._number);
+    } catch (OrmException | IOException | UpdateException e) {
+      throw new RestApiException("Cannot cherry pick", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
index 975e6c1..a4fe39b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
@@ -26,5 +26,6 @@
     factory(TagApiImpl.Factory.class);
     factory(ProjectApiImpl.Factory.class);
     factory(ChildProjectApiImpl.Factory.class);
+    factory(CommitApiImpl.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index e29d633..025b62a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
+import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
@@ -39,6 +40,7 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ChildProjectsCollection;
+import com.google.gerrit.server.project.CommitsCollection;
 import com.google.gerrit.server.project.CreateProject;
 import com.google.gerrit.server.project.DeleteBranches;
 import com.google.gerrit.server.project.DeleteTags;
@@ -89,6 +91,8 @@
   private final ListTags listTags;
   private final DeleteBranches deleteBranches;
   private final DeleteTags deleteTags;
+  private final CommitsCollection commitsCollection;
+  private final CommitApiImpl.Factory commitApi;
 
   @AssistedInject
   ProjectApiImpl(
@@ -111,6 +115,8 @@
       ListTags listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
       @Assisted ProjectResource project) {
     this(
         user,
@@ -133,6 +139,8 @@
         deleteBranches,
         deleteTags,
         project,
+        commitsCollection,
+        commitApi,
         null);
   }
 
@@ -157,6 +165,8 @@
       ListTags listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
       @Assisted String name) {
     this(
         user,
@@ -179,6 +189,8 @@
         deleteBranches,
         deleteTags,
         null,
+        commitsCollection,
+        commitApi,
         name);
   }
 
@@ -203,6 +215,8 @@
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
       ProjectResource project,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
       String name) {
     this.user = user;
     this.createProjectFactory = createProjectFactory;
@@ -225,6 +239,8 @@
     this.listTags = listTags;
     this.deleteBranches = deleteBranches;
     this.deleteTags = deleteTags;
+    this.commitsCollection = commitsCollection;
+    this.commitApi = commitApi;
   }
 
   @Override
@@ -393,6 +409,15 @@
     }
   }
 
+  @Override
+  public CommitApi commit(String commit) throws RestApiException {
+    try {
+      return commitApi.create(commitsCollection.parse(checkExists(), IdString.fromDecoded(commit)));
+    } catch (IOException e) {
+      throw new RestApiException("Cannot parse commit", e);
+    }
+  }
+
   private ProjectResource checkExists() throws ResourceNotFoundException {
     if (project == null) {
       throw new ResourceNotFoundException(name);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 7feb745..1a8d916 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
@@ -29,9 +29,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 66b279f..9bcf3d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 
 import com.google.common.base.Strings;
 import com.google.common.cache.CacheLoader;
@@ -30,8 +30,9 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.EmailExpander;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -319,21 +320,19 @@
 
   static class UserLoader extends CacheLoader<String, Optional<Account.Id>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final ExternalIds externalIds;
 
     @Inject
-    UserLoader(SchemaFactory<ReviewDb> schema) {
+    UserLoader(SchemaFactory<ReviewDb> schema, ExternalIds externalIds) {
       this.schema = schema;
+      this.externalIds = externalIds;
     }
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
         return Optional.ofNullable(
-                ExternalId.from(
-                    db.accountExternalIds()
-                        .get(
-                            ExternalId.Key.create(SCHEME_GERRIT, username)
-                                .asAccountExternalIdKey())))
+                externalIds.get(db, ExternalId.Key.create(SCHEME_GERRIT, username)))
             .map(ExternalId::accountId);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
index d30e667..75f4213 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.openid;
 
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 public class OpenIdProviderPattern {
   public static OpenIdProviderPattern create(String pattern) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
index 862f4e8..8770ac1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -69,25 +69,22 @@
 
     metrics.newTrigger(
         cacheMetrics,
-        new Runnable() {
-          @Override
-          public void run() {
-            for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-              Cache<?, ?> c = e.getProvider().get();
-              String name = metricNameOf(e);
-              CacheStats cstats = c.stats();
-              memEnt.set(name, c.size());
-              memHit.set(name, cstats.hitRate() * 100);
-              memEvict.set(name, cstats.evictionCount());
-              if (c instanceof PersistentCache) {
-                PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
-                perDiskEnt.set(name, d.size());
-                perDiskHit.set(name, hitRatio(d));
-              }
+        () -> {
+          for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+            Cache<?, ?> c = e.getProvider().get();
+            String name = metricNameOf(e);
+            CacheStats cstats = c.stats();
+            memEnt.set(name, c.size());
+            memHit.set(name, cstats.hitRate() * 100);
+            memEvict.set(name, cstats.evictionCount());
+            if (c instanceof PersistentCache) {
+              PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
+              perDiskEnt.set(name, d.size());
+              perDiskHit.set(name, hitRatio(d));
             }
-            for (CallbackMetric<?> cbm : cacheMetrics) {
-              cbm.prune();
-            }
+          }
+          for (CallbackMetric<?> cbm : cacheMetrics) {
+            cbm.prune();
           }
         });
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
index 519a4bc..af619d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
@@ -122,6 +122,7 @@
     copy.mergeable = changeInfo.mergeable;
     copy.insertions = changeInfo.insertions;
     copy.deletions = changeInfo.deletions;
+    copy.isPrivate = changeInfo.isPrivate;
     copy.subject = changeInfo.subject;
     copy.status = changeInfo.status;
     copy.owner = changeInfo.owner;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
index 73ef23e..3fefcd4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ArchiveFormat.java
@@ -68,6 +68,10 @@
     @SuppressWarnings("unchecked")
     ArchiveCommand.Format<T> fmt = (Format<T>) format;
     fmt.putEntry(
-        out, path, FileMode.REGULAR_FILE, new ObjectLoader.SmallObject(FileMode.TYPE_FILE, data));
+        out,
+        null,
+        path,
+        FileMode.REGULAR_FILE,
+        new ObjectLoader.SmallObject(FileMode.TYPE_FILE, data));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index da34064..9d7f4cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -110,6 +110,7 @@
   private String topic;
   private String message;
   private String patchSetDescription;
+  private boolean isPrivate;
   private List<String> groups = Collections.emptyList();
   private CommitValidators.Policy validatePolicy = CommitValidators.Policy.GERRIT;
   private NotifyHandling notify = NotifyHandling.ALL;
@@ -184,6 +185,7 @@
             ctx.getWhen());
     change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
     change.setTopic(topic);
+    change.setPrivate(isPrivate);
     return change;
   }
 
@@ -259,6 +261,12 @@
     return this;
   }
 
+  public ChangeInserter setPrivate(boolean isPrivate) {
+    checkState(change == null, "setPrivate(boolean) only valid before creating change");
+    this.isPrivate = isPrivate;
+    return this;
+  }
+
   public ChangeInserter setDraft(boolean draft) {
     checkState(change == null, "setDraft(boolean) only valid before creating change");
     return setStatus(draft ? Change.Status.DRAFT : Change.Status.NEW);
@@ -351,6 +359,9 @@
     update.setBranch(change.getDest().get());
     update.setTopic(change.getTopic());
     update.setPsDescription(patchSetDescription);
+    if (isPrivate) {
+      update.setPrivate(isPrivate);
+    }
 
     boolean draft = status == Change.Status.DRAFT;
     List<String> newGroups = groups;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index 41d101b..066c1a2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -108,6 +108,7 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -122,7 +123,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -214,7 +214,7 @@
   private AccountLoader accountLoader;
   private FixInput fix;
 
-  @AssistedInject
+  @Inject
   ChangeJson(
       Provider<ReviewDb> db,
       LabelNormalizer ln,
@@ -439,6 +439,7 @@
       info.updated = c.getLastUpdatedOn();
       info._number = c.getId().get();
       info.problems = result.problems();
+      info.isPrivate = c.isPrivate();
       finish(info);
     } else {
       info = new ChangeInfo();
@@ -491,6 +492,7 @@
       out.insertions = changedLines.get().insertions;
       out.deletions = changedLines.get().deletions;
     }
+    out.isPrivate = in.isPrivate();
     out.subject = in.getSubject();
     out.status = in.getStatus().asChangeStatus();
     out.owner = accountLoader.get(in.getOwner());
@@ -530,6 +532,11 @@
           cd.reviewers().asTable().rowMap().entrySet()) {
         out.reviewers.put(e.getKey().asReviewerState(), toAccountInfo(e.getValue().keySet()));
       }
+      for (Map.Entry<ReviewerStateInternal, Map<Address, Timestamp>> e :
+          cd.reviewersByEmail().asTable().rowMap().entrySet()) {
+        out.reviewers.put(
+            e.getKey().asReviewerState(), toAccountInfoByEmail(e.getValue().keySet()));
+      }
 
       out.removableReviewers = removableReviewers(ctl, out);
     }
@@ -1029,6 +1036,10 @@
         cmi.message = message.getMessage();
         cmi.tag = message.getTag();
         cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
+        Account.Id realAuthor = message.getRealAuthor();
+        if (realAuthor != null) {
+          cmi.realAuthor = accountLoader.get(realAuthor);
+        }
         result.add(cmi);
       }
     }
@@ -1070,9 +1081,11 @@
     Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
     if (ccs != null) {
       for (AccountInfo ai : ccs) {
-        Account.Id id = new Account.Id(ai._accountId);
-        if (ctl.canRemoveReviewer(id, 0)) {
-          removable.add(id);
+        if (ai._accountId != null) {
+          Account.Id id = new Account.Id(ai._accountId);
+          if (ctl.canRemoveReviewer(id, 0)) {
+            removable.add(id);
+          }
         }
       }
     }
@@ -1086,6 +1099,14 @@
     for (Account.Id id : removable) {
       result.add(accountLoader.get(id));
     }
+    // Reviewers added by email are always removable
+    for (Collection<AccountInfo> infos : out.reviewers.values()) {
+      for (AccountInfo info : infos) {
+        if (info._accountId == null) {
+          result.add(info);
+        }
+      }
+    }
     return result;
   }
 
@@ -1097,6 +1118,14 @@
         .collect(toList());
   }
 
+  private Collection<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
+    return addresses
+        .stream()
+        .map(a -> new AccountInfo(a.getName(), a.getEmail()))
+        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
+        .collect(toList());
+  }
+
   @Nullable
   private Repository openRepoIfNecessary(ChangeControl ctl) throws IOException {
     if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
index b06f05f..ab48827 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
@@ -32,9 +32,9 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class ChangeResource implements RestResource, HasETag {
@@ -56,7 +56,7 @@
   private final StarredChangesUtil starredChangesUtil;
   private final ChangeControl control;
 
-  @AssistedInject
+  @Inject
   ChangeResource(StarredChangesUtil starredChangesUtil, @Assisted ChangeControl control) {
     this.starredChangesUtil = starredChangesUtil;
     this.control = control;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
index e5a4d0f..18d2fc1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
@@ -66,9 +66,7 @@
       throw new BadRequestException("destination must be non-empty");
     }
 
-    @SuppressWarnings("resource")
-    ReviewDb db = dbProvider.get();
-    if (!control.isVisible(db)) {
+    if (!control.isVisible(dbProvider.get())) {
       throw new AuthException("Cherry pick not permitted");
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index 1e5aa45..826c8a0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -42,7 +43,6 @@
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -59,9 +59,6 @@
 import java.sql.Timestamp;
 import java.util.List;
 import java.util.TimeZone;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -114,23 +111,42 @@
   }
 
   public Change.Id cherryPick(
-      Change change,
-      PatchSet patch,
-      final String message,
-      final String ref,
-      final RefControl refControl,
-      int parent)
-      throws NoSuchChangeException, OrmException, MissingObjectException,
-          IncorrectObjectTypeException, IOException, InvalidChangeOperationException,
-          IntegrationException, UpdateException, RestApiException {
+      Change change, PatchSet patch, String message, String ref, RefControl refControl, int parent)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException {
+    return cherryPick(
+        change.getId(),
+        patch.getId(),
+        change.getDest(),
+        change.getTopic(),
+        change.getProject(),
+        ObjectId.fromString(patch.getRevision().get()),
+        message,
+        ref,
+        refControl,
+        parent);
+  }
 
-    if (Strings.isNullOrEmpty(ref)) {
+  public Change.Id cherryPick(
+      @Nullable Change.Id sourceChangeId,
+      @Nullable PatchSet.Id sourcePatchId,
+      @Nullable Branch.NameKey sourceBranch,
+      @Nullable String sourceChangeTopic,
+      Project.NameKey project,
+      ObjectId sourceCommit,
+      String message,
+      String targetRef,
+      RefControl targetRefControl,
+      int parent)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException {
+
+    if (Strings.isNullOrEmpty(targetRef)) {
       throw new InvalidChangeOperationException(
           "Cherry Pick: Destination branch cannot be null or empty");
     }
 
-    Project.NameKey project = change.getProject();
-    String destinationBranch = RefNames.shortName(ref);
+    String destinationBranch = RefNames.shortName(targetRef);
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
         // This inserter and revwalk *must* be passed to any BatchUpdates
@@ -138,7 +154,7 @@
         // before patch sets are updated.
         ObjectInserter oi = git.newObjectInserter();
         CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(oi.newReader())) {
-      Ref destRef = git.getRefDatabase().exactRef(ref);
+      Ref destRef = git.getRefDatabase().exactRef(targetRef);
       if (destRef == null) {
         throw new InvalidChangeOperationException(
             String.format("Branch %s does not exist.", destinationBranch));
@@ -146,8 +162,7 @@
 
       CodeReviewCommit mergeTip = revWalk.parseCommit(destRef.getObjectId());
 
-      CodeReviewCommit commitToCherryPick =
-          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
+      CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
 
       if (parent <= 0 || parent > commitToCherryPick.getParentCount()) {
         throw new InvalidChangeOperationException(
@@ -171,7 +186,7 @@
 
       CodeReviewCommit cherryPickCommit;
       try {
-        ProjectState projectState = refControl.getProjectControl().getProjectState();
+        ProjectState projectState = targetRefControl.getProjectControl().getProjectState();
         cherryPickCommit =
             mergeUtilFactory
                 .create(projectState)
@@ -195,7 +210,7 @@
           changeKey = new Change.Key("I" + computedChangeId.name());
         }
 
-        Branch.NameKey newDest = new Branch.NameKey(change.getProject(), destRef.getName());
+        Branch.NameKey newDest = new Branch.NameKey(project, destRef.getName());
         List<ChangeData> destChanges =
             queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
         if (destChanges.size() > 1) {
@@ -205,32 +220,37 @@
                   + " reside on the same branch. "
                   + "Cannot create a new patch set.");
         }
-        try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                db.get(), change.getDest().getParentKey(), identifiedUser, now)) {
+        try (BatchUpdate bu = batchUpdateFactory.create(db.get(), project, identifiedUser, now)) {
           bu.setRepository(git, revWalk, oi);
           Change.Id result;
           if (destChanges.size() == 1) {
             // The change key exists on the destination branch. The cherry pick
             // will be added as a new patch set.
             ChangeControl destCtl =
-                refControl.getProjectControl().controlFor(destChanges.get(0).notes());
+                targetRefControl.getProjectControl().controlFor(destChanges.get(0).notes());
             result = insertPatchSet(bu, git, destCtl, cherryPickCommit);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
             String newTopic = null;
-            if (!Strings.isNullOrEmpty(change.getTopic())) {
-              newTopic = change.getTopic() + "-" + newDest.getShortName();
+            if (!Strings.isNullOrEmpty(sourceChangeTopic)) {
+              newTopic = sourceChangeTopic + "-" + newDest.getShortName();
             }
             result =
                 createNewChange(
-                    bu, cherryPickCommit, refControl.getRefName(), newTopic, change.getDest());
+                    bu,
+                    cherryPickCommit,
+                    targetRefControl.getRefName(),
+                    newTopic,
+                    sourceBranch,
+                    sourceCommit);
 
-            bu.addOp(
-                change.getId(),
-                new AddMessageToSourceChangeOp(
-                    changeMessagesUtil, patch.getId(), destinationBranch, cherryPickCommit));
+            if (sourceChangeId != null && sourcePatchId != null) {
+              bu.addOp(
+                  sourceChangeId,
+                  new AddMessageToSourceChangeOp(
+                      changeMessagesUtil, sourcePatchId, destinationBranch, cherryPickCommit));
+            }
           }
           bu.execute();
           return result;
@@ -238,8 +258,6 @@
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationException("Cherry pick failed: " + e.getMessage());
       }
-    } catch (RepositoryNotFoundException e) {
-      throw new NoSuchChangeException(change.getId(), e);
     }
   }
 
@@ -266,7 +284,8 @@
       CodeReviewCommit cherryPickCommit,
       String refName,
       String topic,
-      Branch.NameKey sourceBranch)
+      Branch.NameKey sourceBranch,
+      ObjectId sourceCommit)
       throws OrmException {
     Change.Id changeId = new Change.Id(seq.nextChangeId());
     ChangeInserter ins =
@@ -275,7 +294,7 @@
             .setValidatePolicy(CommitValidators.Policy.GERRIT)
             .setTopic(topic);
 
-    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch));
+    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch, sourceCommit));
     bu.insertChange(ins);
     return changeId;
   }
@@ -317,12 +336,16 @@
     }
   }
 
-  private String messageForDestinationChange(PatchSet.Id patchSetId, Branch.NameKey sourceBranch) {
-    return new StringBuilder("Patch Set ")
-        .append(patchSetId.get())
-        .append(": Cherry Picked from branch ")
-        .append(sourceBranch.getShortName())
-        .append(".")
-        .toString();
+  private String messageForDestinationChange(
+      PatchSet.Id patchSetId, Branch.NameKey sourceBranch, ObjectId sourceCommit) {
+    StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
+
+    if (sourceBranch != null) {
+      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
+    } else {
+      stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
+    }
+
+    return stringBuilder.append(".").toString();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
new file mode 100644
index 0000000..cf99d37
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class CherryPickCommit implements RestModifyView<CommitResource, CherryPickInput> {
+
+  private final CherryPickChange cherryPickChange;
+  private final ChangeJson.Factory json;
+
+  @Inject
+  CherryPickCommit(CherryPickChange cherryPickChange, ChangeJson.Factory json) {
+    this.cherryPickChange = cherryPickChange;
+    this.json = json;
+  }
+
+  @Override
+  public ChangeInfo apply(CommitResource rsrc, CherryPickInput input)
+      throws OrmException, IOException, UpdateException, RestApiException {
+    String message = Strings.nullToEmpty(input.message).trim();
+    String destination = Strings.nullToEmpty(input.destination).trim();
+    int parent = input.parent == null ? 1 : input.parent;
+
+    if (destination.isEmpty()) {
+      throw new BadRequestException("destination must be non-empty");
+    }
+
+    ProjectControl projectControl = rsrc.getProject();
+    Capable capable = projectControl.canPushToAtLeastOneRef();
+    if (capable != Capable.OK) {
+      throw new AuthException(capable.getMessage());
+    }
+
+    RevCommit commit = rsrc.getCommit();
+    String refName = RefNames.fullName(destination);
+    RefControl refControl = projectControl.controlForRef(refName);
+    if (!refControl.canUpload()) {
+      throw new AuthException("Not allowed to cherry pick " + commit + " to " + destination);
+    }
+
+    Project.NameKey project = projectControl.getProject().getNameKey();
+    try {
+      Change.Id cherryPickedChangeId =
+          cherryPickChange.cherryPick(
+              null,
+              null,
+              null,
+              null,
+              project,
+              commit,
+              message.isEmpty() ? commit.getFullMessage() : message,
+              refName,
+              refControl,
+              parent);
+      return json.noOptions().format(project, cherryPickedChangeId);
+    } catch (InvalidChangeOperationException e) {
+      throw new BadRequestException(e.getMessage());
+    } catch (IntegrationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index 5032e57..6536550f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -122,7 +122,7 @@
 
       commentsUtil.putComments(
           ctx.getDb(), ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
index 7787260..d1b26ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -101,7 +101,7 @@
       Comment c = maybeComment.get();
       setCommentRevId(c, patchListCache, ctx.getChange(), ps);
       commentsUtil.deleteComments(ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
new file mode 100644
index 0000000..b91c7aa
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeletePrivate
+    implements RestModifyView<ChangeResource, DeletePrivate.Input>, UiAction<ChangeResource> {
+  public static class Input {}
+
+  private final Provider<ReviewDb> dbProvider;
+  private final BatchUpdate.Factory batchUpdateFactory;
+
+  @Inject
+  DeletePrivate(Provider<ReviewDb> dbProvider, BatchUpdate.Factory batchUpdateFactory) {
+    this.dbProvider = dbProvider;
+    this.batchUpdateFactory = batchUpdateFactory;
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, DeletePrivate.Input input)
+      throws RestApiException, UpdateException {
+    if (!rsrc.getControl().isOwner()) {
+      throw new AuthException("not allowed to unmark private");
+    }
+
+    if (!rsrc.getChange().isPrivate()) {
+      throw new ResourceConflictException("change is not private");
+    }
+
+    ChangeControl control = rsrc.getControl();
+    SetPrivateOp op = new SetPrivateOp(false);
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(),
+            control.getProject().getNameKey(),
+            control.getUser(),
+            TimeUtil.nowTs())) {
+      u.addOp(control.getId(), op).execute();
+    }
+
+    return Response.none();
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unmark private")
+        .setTitle("Unmark change as private")
+        .setVisible(rsrc.getControl().isOwner());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
index 1485d03..4822478 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
@@ -14,92 +14,38 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.Iterables;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
 public class DeleteReviewer implements RestModifyView<ReviewerResource, DeleteReviewerInput> {
-  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
 
   private final Provider<ReviewDb> dbProvider;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ReviewerDeleted reviewerDeleted;
-  private final Provider<IdentifiedUser> user;
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
+  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
+  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
 
   @Inject
   DeleteReviewer(
       Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
       BatchUpdate.Factory batchUpdateFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      ReviewerDeleted reviewerDeleted,
-      Provider<IdentifiedUser> user,
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
-      NotesMigration migration,
-      NotifyUtil notifyUtil) {
+      DeleteReviewerOp.Factory deleteReviewerOpFactory,
+      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
     this.dbProvider = dbProvider;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.userFactory = userFactory;
-    this.reviewerDeleted = reviewerDeleted;
-    this.user = user;
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
+    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
+    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
   }
 
   @Override
@@ -118,151 +64,15 @@
             rsrc.getChangeResource().getProject(),
             rsrc.getChangeResource().getUser(),
             TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getReviewerUser().getAccount(), input);
+      BatchUpdateOp op;
+      if (rsrc.isByEmail()) {
+        op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail(), input);
+      } else {
+        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
+      }
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
     }
-
     return Response.none();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final Account reviewer;
-    private final DeleteReviewerInput input;
-    ChangeMessage changeMessage;
-    Change currChange;
-    PatchSet currPs;
-    Map<String, Short> newApprovals = new HashMap<>();
-    Map<String, Short> oldApprovals = new HashMap<>();
-
-    Op(Account reviewerAccount, DeleteReviewerInput input) {
-      this.reviewer = reviewerAccount;
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws AuthException, ResourceNotFoundException, OrmException {
-      Account.Id reviewerId = reviewer.getId();
-      if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
-        throw new ResourceNotFoundException();
-      }
-      currChange = ctx.getChange();
-      currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
-
-      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      // removing a reviewer will remove all her votes
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        newApprovals.put(lt.getName(), (short) 0);
-      }
-
-      StringBuilder msg = new StringBuilder();
-      msg.append("Removed reviewer " + reviewer.getFullName());
-      StringBuilder removedVotesMsg = new StringBuilder();
-      removedVotesMsg.append(" with the following votes:\n\n");
-      List<PatchSetApproval> del = new ArrayList<>();
-      boolean votesRemoved = false;
-      for (PatchSetApproval a : approvals(ctx, reviewerId)) {
-        if (ctx.getControl().canRemoveReviewer(a)) {
-          del.add(a);
-          if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
-            oldApprovals.put(a.getLabel(), a.getValue());
-            removedVotesMsg
-                .append("* ")
-                .append(a.getLabel())
-                .append(formatLabelValue(a.getValue()))
-                .append(" by ")
-                .append(userFactory.create(a.getAccountId()).getNameEmail())
-                .append("\n");
-            votesRemoved = true;
-          }
-        } else {
-          throw new AuthException("delete reviewer not permitted");
-        }
-      }
-
-      if (votesRemoved) {
-        msg.append(removedVotesMsg);
-      } else {
-        msg.append(".");
-      }
-      ctx.getDb().patchSetApprovals().delete(del);
-      ChangeUpdate update = ctx.getUpdate(currPs.getId());
-      update.removeReviewer(reviewerId);
-
-      changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
-      cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
-
-      return true;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage);
-      }
-      reviewerDeleted.fire(
-          currChange,
-          currPs,
-          reviewer,
-          ctx.getAccount(),
-          changeMessage.getMessage(),
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          ctx.getWhen());
-    }
-
-    private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
-        throws OrmException {
-      Change.Id changeId = ctx.getNotes().getChangeId();
-      Iterable<PatchSetApproval> approvals;
-      PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
-
-      if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
-        // Because NoteDb and ReviewDb have different semantics for zero-value
-        // approvals, we must fall back to ReviewDb as the source of truth here.
-        ReviewDb db = ctx.getDb();
-
-        if (db instanceof BatchUpdateReviewDb) {
-          db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-        }
-        db = ReviewDbUtil.unwrapDb(db);
-        approvals = db.patchSetApprovals().byChange(changeId);
-      } else {
-        approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
-      }
-
-      return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
-    }
-
-    private String formatLabelValue(short value) {
-      if (value > 0) {
-        return "+" + value;
-      }
-      return Short.toString(value);
-    }
-
-    private void emailReviewers(
-        Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
-      Account.Id userId = user.get().getAccountId();
-      if (userId.equals(reviewer.getId())) {
-        // The user knows they removed themselves, don't bother emailing them.
-        return;
-      }
-      try {
-        DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
-        cm.setFrom(userId);
-        cm.addReviewers(Collections.singleton(reviewer.getId()));
-        cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-        cm.setNotify(input.notify);
-        cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot email update for change " + change.getId(), err);
-      }
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
new file mode 100644
index 0000000..adfe3f5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collections;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerByEmailOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+
+  public interface Factory {
+    DeleteReviewerByEmailOp create(Address reviewer, DeleteReviewerInput input);
+  }
+
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotifyUtil notifyUtil;
+  private final Address reviewer;
+  private final DeleteReviewerInput input;
+
+  private ChangeMessage changeMessage;
+  private Change.Id changeId;
+
+  @Inject
+  DeleteReviewerByEmailOp(
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotifyUtil notifyUtil,
+      @Assisted Address reviewer,
+      @Assisted DeleteReviewerInput input) {
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.notifyUtil = notifyUtil;
+    this.reviewer = reviewer;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException {
+    changeId = ctx.getChange().getId();
+    PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+    String msg = "Removed reviewer " + reviewer;
+    changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(changeId, ChangeUtil.messageUuid()),
+            ctx.getAccountId(),
+            ctx.getWhen(),
+            psId);
+    changeMessage.setMessage(msg);
+
+    ctx.getUpdate(psId).setChangeMessage(msg);
+    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (!NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      return;
+    }
+    try {
+      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(ctx.getProject(), changeId);
+      cm.setFrom(ctx.getAccountId());
+      cm.addReviewersByEmail(Collections.singleton(reviewer));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + changeId, err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
new file mode 100644
index 0000000..a255f79
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -0,0 +1,232 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ReviewerDeleted;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+
+  public interface Factory {
+    DeleteReviewerOp create(Account reviewerAccount, DeleteReviewerInput input);
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ReviewerDeleted reviewerDeleted;
+  private final Provider<IdentifiedUser> user;
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
+
+  private final Account reviewer;
+  private final DeleteReviewerInput input;
+
+  ChangeMessage changeMessage;
+  Change currChange;
+  PatchSet currPs;
+  Map<String, Short> newApprovals = new HashMap<>();
+  Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  DeleteReviewerOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      IdentifiedUser.GenericFactory userFactory,
+      ReviewerDeleted reviewerDeleted,
+      Provider<IdentifiedUser> user,
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotesMigration migration,
+      NotifyUtil notifyUtil,
+      @Assisted Account reviewerAccount,
+      @Assisted DeleteReviewerInput input) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.userFactory = userFactory;
+    this.reviewerDeleted = reviewerDeleted;
+    this.user = user;
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.migration = migration;
+    this.notifyUtil = notifyUtil;
+
+    this.reviewer = reviewerAccount;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, OrmException {
+    Account.Id reviewerId = reviewer.getId();
+    if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
+      throw new ResourceNotFoundException();
+    }
+    currChange = ctx.getChange();
+    currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
+
+    LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+    // removing a reviewer will remove all her votes
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      newApprovals.put(lt.getName(), (short) 0);
+    }
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed reviewer " + reviewer.getFullName());
+    StringBuilder removedVotesMsg = new StringBuilder();
+    removedVotesMsg.append(" with the following votes:\n\n");
+    List<PatchSetApproval> del = new ArrayList<>();
+    boolean votesRemoved = false;
+    for (PatchSetApproval a : approvals(ctx, reviewerId)) {
+      if (ctx.getControl().canRemoveReviewer(a)) {
+        del.add(a);
+        if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
+          oldApprovals.put(a.getLabel(), a.getValue());
+          removedVotesMsg
+              .append("* ")
+              .append(a.getLabel())
+              .append(formatLabelValue(a.getValue()))
+              .append(" by ")
+              .append(userFactory.create(a.getAccountId()).getNameEmail())
+              .append("\n");
+          votesRemoved = true;
+        }
+      } else {
+        throw new AuthException("delete reviewer not permitted");
+      }
+    }
+
+    if (votesRemoved) {
+      msg.append(removedVotesMsg);
+    } else {
+      msg.append(".");
+    }
+    ctx.getDb().patchSetApprovals().delete(del);
+    ChangeUpdate update = ctx.getUpdate(currPs.getId());
+    update.removeReviewer(reviewerId);
+
+    changeMessage =
+        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
+    cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
+
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      emailReviewers(ctx.getProject(), currChange, changeMessage);
+    }
+    reviewerDeleted.fire(
+        currChange,
+        currPs,
+        reviewer,
+        ctx.getAccount(),
+        changeMessage.getMessage(),
+        newApprovals,
+        oldApprovals,
+        input.notify,
+        ctx.getWhen());
+  }
+
+  private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
+      throws OrmException {
+    Change.Id changeId = ctx.getNotes().getChangeId();
+    Iterable<PatchSetApproval> approvals;
+    PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
+
+    if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
+      // Because NoteDb and ReviewDb have different semantics for zero-value
+      // approvals, we must fall back to ReviewDb as the source of truth here.
+      ReviewDb db = ctx.getDb();
+
+      if (db instanceof BatchUpdateReviewDb) {
+        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+      }
+      db = ReviewDbUtil.unwrapDb(db);
+      approvals = db.patchSetApprovals().byChange(changeId);
+    } else {
+      approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
+    }
+
+    return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
+  }
+
+  private String formatLabelValue(short value) {
+    if (value > 0) {
+      return "+" + value;
+    }
+    return Short.toString(value);
+  }
+
+  private void emailReviewers(
+      Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
+    Account.Id userId = user.get().getAccountId();
+    if (userId.equals(reviewer.getId())) {
+      // The user knows they removed themselves, don't bother emailing them.
+      return;
+    }
+    try {
+      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
+      cm.setFrom(userId);
+      cm.addReviewers(Collections.singleton(reviewer.getId()));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + change.getId(), err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
index 27ec89d..1dba58c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -48,11 +49,16 @@
 
   @Override
   public List<ReviewerInfo> apply(ChangeResource rsrc) throws OrmException {
-    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId)) {
-        reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address adr : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(adr.toString())) {
+        reviewers.put(adr.toString(), new ReviewerResource(rsrc, adr));
       }
     }
     return json.format(reviewers.values());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
index d0c8ca0..5aaee56 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -54,11 +55,16 @@
       throw new MethodNotAllowedException("Cannot list reviewers on non-current patch set");
     }
 
-    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId)) {
-        reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address address : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(address.toString())) {
+        reviewers.put(address.toString(), new ReviewerResource(rsrc, address));
       }
     }
     return json.format(reviewers.values());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index aca6ef1..f23ab0c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -82,6 +82,8 @@
     post(CHANGE_KIND, "index").to(Index.class);
     post(CHANGE_KIND, "rebuild.notedb").to(Rebuild.class);
     post(CHANGE_KIND, "move").to(Move.class);
+    put(CHANGE_KIND, "private").to(PutPrivate.class);
+    delete(CHANGE_KIND, "private").to(DeletePrivate.class);
 
     post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
@@ -159,5 +161,7 @@
     factory(SetAssigneeOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
     factory(ChangeResource.Factory.class);
+    factory(DeleteReviewerOp.Factory.class);
+    factory(DeleteReviewerByEmailOp.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 7cf62a0..b656969 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -50,8 +50,8 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
@@ -106,7 +106,7 @@
   private ChangeMessage changeMessage;
   private ReviewerSet oldReviewers;
 
-  @AssistedInject
+  @Inject
   public PatchSetInserter(
       ApprovalsUtil approvalsUtil,
       ApprovalCopier approvalCopier,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 76cc7e8..bd7939d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -86,6 +86,7 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -313,14 +314,18 @@
       ListMultimap<RecipientType, Account.Id> accountsToNotify) {
     List<Account.Id> to = new ArrayList<>();
     List<Account.Id> cc = new ArrayList<>();
+    List<Address> toByEmail = new ArrayList<>();
+    List<Address> ccByEmail = new ArrayList<>();
     for (PostReviewers.Addition addition : reviewerAdditions) {
       if (addition.op.state == ReviewerState.REVIEWER) {
         to.addAll(addition.op.reviewers.keySet());
+        toByEmail.addAll(addition.op.reviewersByEmail);
       } else if (addition.op.state == ReviewerState.CC) {
         cc.addAll(addition.op.reviewers.keySet());
+        ccByEmail.addAll(addition.op.reviewersByEmail);
       }
     }
-    postReviewers.emailReviewers(change, to, cc, notify, accountsToNotify);
+    postReviewers.emailReviewers(change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify);
   }
 
   private RevisionResource onBehalfOf(RevisionResource rev, ReviewInput in)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index 7031d51..9df0bd7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NoSuchGroupException;
@@ -32,6 +33,7 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -42,6 +44,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -52,12 +55,17 @@
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
+import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -104,6 +112,8 @@
   private final NotesMigration migration;
   private final AccountCache accountCache;
   private final NotifyUtil notifyUtil;
+  private final ProjectCache projectCache;
+  private final Provider<AnonymousUser> anonymousProvider;
 
   @Inject
   PostReviewers(
@@ -124,7 +134,9 @@
       ReviewerAdded reviewerAdded,
       NotesMigration migration,
       AccountCache accountCache,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      ProjectCache projectCache,
+      Provider<AnonymousUser> anonymousProvider) {
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
     this.approvalsUtil = approvalsUtil;
@@ -143,6 +155,8 @@
     this.migration = migration;
     this.accountCache = accountCache;
     this.notifyUtil = notifyUtil;
+    this.projectCache = projectCache;
+    this.anonymousProvider = anonymousProvider;
   }
 
   @Override
@@ -167,6 +181,7 @@
     return addition.result;
   }
 
+  // TODO(hiesel) Refactor this as it starts to become unreadable
   public Addition prepareApplication(
       ChangeResource rsrc, AddReviewerInput input, boolean allowGroup)
       throws OrmException, RestApiException, IOException {
@@ -174,17 +189,28 @@
     try {
       accountId = accounts.parse(input.reviewer).getAccountId();
     } catch (UnprocessableEntityException e) {
+      ProjectConfig projectConfig = projectCache.checkedGet(rsrc.getProject()).getConfig();
       if (allowGroup) {
         try {
           return putGroup(rsrc, input);
         } catch (UnprocessableEntityException e2) {
-          throw new UnprocessableEntityException(
-              MessageFormat.format(
-                  ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
+          if (!projectConfig.getEnableReviewerByEmail()) {
+            throw new UnprocessableEntityException(
+                MessageFormat.format(
+                    ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
+          }
         }
       }
-      throw new UnprocessableEntityException(
-          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, input.reviewer));
+      if (!projectConfig.getEnableReviewerByEmail()) {
+        throw new UnprocessableEntityException(
+            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, input.reviewer));
+      }
+      return putAccountByEmail(
+          input.reviewer,
+          rsrc,
+          input.state(),
+          input.notify,
+          notifyUtil.resolveAccounts(input.notifyDetails));
     }
     return putAccount(
         input.reviewer,
@@ -199,6 +225,7 @@
         user.getUserName(),
         revision.getChangeResource(),
         ImmutableMap.of(user.getAccountId(), revision.getControl()),
+        null,
         CC,
         NotifyHandling.NONE,
         ImmutableListMultimap.of());
@@ -218,6 +245,7 @@
           reviewer,
           rsrc.getChangeResource(),
           ImmutableMap.of(member.getId(), control),
+          null,
           state,
           notify,
           accountsToNotify);
@@ -228,6 +256,32 @@
     throw new UnprocessableEntityException(String.format("Account of %s is inactive.", reviewer));
   }
 
+  private Addition putAccountByEmail(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws UnprocessableEntityException, OrmException, BadRequestException {
+    if (!rsrc.getControl().forUser(anonymousProvider.get()).isVisible(dbProvider.get())) {
+      throw new BadRequestException("change is not publicly visible");
+    }
+    if (!migration.readChanges()) {
+      throw new BadRequestException("feature only supported in NoteDb");
+    }
+    Address adr;
+    try {
+      adr = Address.parse(reviewer);
+    } catch (IllegalArgumentException e) {
+      throw new UnprocessableEntityException(String.format("email invalid %s", reviewer));
+    }
+    if (!OutgoingEmailValidator.isValid(adr.getEmail())) {
+      throw new UnprocessableEntityException(String.format("email invalid %s", reviewer));
+    }
+    return new Addition(
+        reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify);
+  }
+
   private Addition putGroup(ChangeResource rsrc, AddReviewerInput input)
       throws RestApiException, OrmException, IOException {
     GroupDescription.Basic group = groupsCollection.parseInternal(input.reviewer);
@@ -283,6 +337,7 @@
         input.reviewer,
         rsrc,
         reviewers,
+        null,
         input.state(),
         input.notify,
         notifyUtil.resolveAccounts(input.notifyDetails));
@@ -314,26 +369,28 @@
     final Op op;
 
     private final Map<Account.Id, ChangeControl> reviewers;
+    private final Collection<Address> reviewersByEmail;
 
     protected Addition(String reviewer) {
-      this(reviewer, null, null, REVIEWER, null, ImmutableListMultimap.of());
+      this(reviewer, null, null, null, REVIEWER, null, ImmutableListMultimap.of());
     }
 
     protected Addition(
         String reviewer,
         ChangeResource rsrc,
-        Map<Account.Id, ChangeControl> reviewers,
+        @Nullable Map<Account.Id, ChangeControl> reviewers,
+        @Nullable Collection<Address> reviewersByEmail,
         ReviewerState state,
         NotifyHandling notify,
         ListMultimap<RecipientType, Account.Id> accountsToNotify) {
       result = new AddReviewerResult(reviewer);
-      if (reviewers == null) {
-        this.reviewers = ImmutableMap.of();
+      this.reviewers = reviewers == null ? ImmutableMap.of() : reviewers;
+      this.reviewersByEmail = reviewersByEmail == null ? ImmutableList.of() : reviewersByEmail;
+      if (reviewers == null && reviewersByEmail == null) {
         op = null;
         return;
       }
-      this.reviewers = reviewers;
-      op = new Op(rsrc, reviewers, state, notify, accountsToNotify);
+      op = new Op(rsrc, this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
     }
 
     void gatherResults() throws OrmException {
@@ -345,6 +402,9 @@
           result.ccs.add(json.format(new ReviewerInfo(accountId.get()), reviewers.get(accountId)));
         }
         accountLoaderFactory.create(true).fill(result.ccs);
+        for (Address a : reviewersByEmail) {
+          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
+        }
       } else {
         result.reviewers = Lists.newArrayListWithCapacity(op.addedReviewers.size());
         for (PatchSetApproval psa : op.addedReviewers) {
@@ -356,17 +416,22 @@
                   ImmutableList.of(psa)));
         }
         accountLoaderFactory.create(true).fill(result.reviewers);
+        for (Address a : reviewersByEmail) {
+          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
+        }
       }
     }
   }
 
   public class Op implements BatchUpdateOp {
     final Map<Account.Id, ChangeControl> reviewers;
+    final Collection<Address> reviewersByEmail;
     final ReviewerState state;
     final NotifyHandling notify;
     final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-    List<PatchSetApproval> addedReviewers;
-    Collection<Account.Id> addedCCs;
+    List<PatchSetApproval> addedReviewers = new ArrayList<>();
+    Collection<Account.Id> addedCCs = new ArrayList<>();
+    Collection<Address> addedCCsByEmail = new ArrayList<>();
 
     private final ChangeResource rsrc;
     private PatchSet patchSet;
@@ -374,11 +439,13 @@
     Op(
         ChangeResource rsrc,
         Map<Account.Id, ChangeControl> reviewers,
+        Collection<Address> reviewersByEmail,
         ReviewerState state,
         NotifyHandling notify,
         ListMultimap<RecipientType, Account.Id> accountsToNotify) {
       this.rsrc = rsrc;
       this.reviewers = reviewers;
+      this.reviewersByEmail = reviewersByEmail;
       this.state = state;
       this.notify = notify;
       this.accountsToNotify = checkNotNull(accountsToNotify);
@@ -387,27 +454,34 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws RestApiException, OrmException, IOException {
-      if (migration.readChanges() && state == CC) {
-        addedCCs =
-            approvalsUtil.addCcs(
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                reviewers.keySet());
-        if (addedCCs.isEmpty()) {
-          return false;
+      if (!reviewers.isEmpty()) {
+        if (migration.readChanges() && state == CC) {
+          addedCCs =
+              approvalsUtil.addCcs(
+                  ctx.getNotes(),
+                  ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+                  reviewers.keySet());
+          if (addedCCs.isEmpty()) {
+            return false;
+          }
+        } else {
+          addedReviewers =
+              approvalsUtil.addReviewers(
+                  ctx.getDb(),
+                  ctx.getNotes(),
+                  ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+                  rsrc.getControl().getLabelTypes(),
+                  rsrc.getChange(),
+                  reviewers.keySet());
+          if (addedReviewers.isEmpty()) {
+            return false;
+          }
         }
-      } else {
-        addedReviewers =
-            approvalsUtil.addReviewers(
-                ctx.getDb(),
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                rsrc.getControl().getLabelTypes(),
-                rsrc.getChange(),
-                reviewers.keySet());
-        if (addedReviewers.isEmpty()) {
-          return false;
-        }
+      }
+
+      for (Address a : reviewersByEmail) {
+        ctx.getUpdate(ctx.getChange().currentPatchSetId())
+            .putReviewerByEmail(a, ReviewerStateInternal.fromReviewerState(state));
       }
 
       patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
@@ -416,26 +490,19 @@
 
     @Override
     public void postUpdate(Context ctx) throws Exception {
-      if (addedReviewers != null || addedCCs != null) {
-        if (addedReviewers == null) {
-          addedReviewers = new ArrayList<>();
-        }
-        if (addedCCs == null) {
-          addedCCs = new ArrayList<>();
-        }
-        emailReviewers(
-            rsrc.getChange(),
-            Lists.transform(addedReviewers, r -> r.getAccountId()),
-            addedCCs,
-            notify,
-            accountsToNotify);
-        if (!addedReviewers.isEmpty()) {
-          List<Account> reviewers =
-              Lists.transform(
-                  addedReviewers, psa -> accountCache.get(psa.getAccountId()).getAccount());
-          reviewerAdded.fire(
-              rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
-        }
+      emailReviewers(
+          rsrc.getChange(),
+          Lists.transform(addedReviewers, r -> r.getAccountId()),
+          addedCCs == null ? ImmutableList.of() : addedCCs,
+          reviewersByEmail,
+          addedCCsByEmail,
+          notify,
+          accountsToNotify);
+      if (!addedReviewers.isEmpty()) {
+        List<Account> reviewers =
+            Lists.transform(
+                addedReviewers, psa -> accountCache.get(psa.getAccountId()).getAccount());
+        reviewerAdded.fire(rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
       }
     }
   }
@@ -444,9 +511,11 @@
       Change change,
       Collection<Account.Id> added,
       Collection<Account.Id> copied,
+      Collection<Address> addedByEmail,
+      Collection<Address> copiedByEmail,
       NotifyHandling notify,
       ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    if (added.isEmpty() && copied.isEmpty()) {
+    if (added.isEmpty() && copied.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
       return;
     }
 
@@ -466,7 +535,7 @@
         toCopy.add(id);
       }
     }
-    if (toMail.isEmpty() && toCopy.isEmpty()) {
+    if (toMail.isEmpty() && toCopy.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
       return;
     }
 
@@ -478,7 +547,9 @@
       cm.setAccountsToNotify(accountsToNotify);
       cm.setFrom(userId);
       cm.addReviewers(toMail);
+      cm.addReviewersByEmail(addedByEmail);
       cm.addExtraCC(toCopy);
+      cm.addExtraCCByEmail(copiedByEmail);
       cm.send();
     } catch (Exception err) {
       log.error("Cannot send email to new reviewers of change " + change.getId(), err);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
index b289da8..ecdb382 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -146,7 +146,7 @@
           update,
           Status.DRAFT,
           Collections.singleton(update(comment, in, ctx.getWhen())));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutPrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutPrivate.java
new file mode 100644
index 0000000..6c9fadf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutPrivate.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PutPrivate
+    implements RestModifyView<ChangeResource, PutPrivate.Input>, UiAction<ChangeResource> {
+  public static class Input {}
+
+  private final Provider<ReviewDb> dbProvider;
+  private final BatchUpdate.Factory batchUpdateFactory;
+
+  @Inject
+  PutPrivate(Provider<ReviewDb> dbProvider, BatchUpdate.Factory batchUpdateFactory) {
+    this.dbProvider = dbProvider;
+    this.batchUpdateFactory = batchUpdateFactory;
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException {
+    if (!rsrc.getControl().isOwner()) {
+      throw new AuthException("not allowed to mark private");
+    }
+
+    if (rsrc.getChange().isPrivate()) {
+      return Response.ok("");
+    }
+
+    ChangeControl control = rsrc.getControl();
+    SetPrivateOp op = new SetPrivateOp(true);
+    try (BatchUpdate u =
+        batchUpdateFactory.create(
+            dbProvider.get(),
+            control.getProject().getNameKey(),
+            control.getUser(),
+            TimeUtil.nowTs())) {
+      u.addOp(control.getId(), op).execute();
+    }
+
+    return Response.created("");
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Mark private")
+        .setTitle("Mark change as private")
+        .setVisible(rsrc.getControl().isOwner());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index c03bb6f..7bd4bc0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -36,8 +36,8 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -75,7 +75,7 @@
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
 
-  @AssistedInject
+  @Inject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtil.Factory mergeUtilFactory,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
index 6ff4a50..f6f7919 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
@@ -36,7 +40,8 @@
 
   private final ChangeResource change;
   private final RevisionResource revision;
-  private final IdentifiedUser user;
+  @Nullable private final IdentifiedUser user;
+  @Nullable private final Address address;
 
   @AssistedInject
   ReviewerResource(
@@ -44,8 +49,9 @@
       @Assisted ChangeResource change,
       @Assisted Account.Id id) {
     this.change = change;
-    this.revision = null;
     this.user = userFactory.create(id);
+    this.revision = null;
+    this.address = null;
   }
 
   @AssistedInject
@@ -56,6 +62,21 @@
     this.revision = revision;
     this.change = revision.getChangeResource();
     this.user = userFactory.create(id);
+    this.address = null;
+  }
+
+  ReviewerResource(ChangeResource change, Address address) {
+    this.change = change;
+    this.address = address;
+    this.revision = null;
+    this.user = null;
+  }
+
+  ReviewerResource(RevisionResource revision, Address address) {
+    this.revision = revision;
+    this.change = revision.getChangeResource();
+    this.address = address;
+    this.user = null;
   }
 
   public ChangeResource getChangeResource() {
@@ -75,10 +96,28 @@
   }
 
   public IdentifiedUser getReviewerUser() {
+    checkArgument(user != null, "no user provided");
     return user;
   }
 
+  public Address getReviewerByEmail() {
+    checkArgument(address != null, "no address provided");
+    return address;
+  }
+
   /**
+   * Check if this resource was constructed by email or by {@code Account.Id}.
+   *
+   * @return true if the resource was constructed by providing an {@code Address}; false if the
+   *     resource was constructed by providing an {@code Account.Id}.
+   */
+  public boolean isByEmail() {
+    return user == null;
+  }
+
+  /**
+   * Get the control for the caller's user.
+   *
    * @return the control for the caller's user (as opposed to the reviewer's user as returned by
    *     {@link #getReviewerControl()}).
    */
@@ -87,10 +126,13 @@
   }
 
   /**
+   * Get the control for the reviewer's user.
+   *
    * @return the control for the reviewer's user (as opposed to the caller's user as returned by
    *     {@link #getControl()}).
    */
   public ChangeControl getReviewerControl() {
+    checkArgument(user != null, "no user provided");
     return change.getControl().forUser(user);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
index 14c74bc..0762f0e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -69,12 +70,26 @@
   @Override
   public ReviewerResource parse(ChangeResource rsrc, IdString id)
       throws OrmException, ResourceNotFoundException, AuthException {
-    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    Address address = Address.tryParse(id.get());
 
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
     // See if the id exists as a reviewer for this change
-    if (fetchAccountIds(rsrc).contains(accountId)) {
+    if (accountId != null && fetchAccountIds(rsrc).contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
     }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
     throw new ResourceNotFoundException(id);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
index d3623cf..2dc7ad8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -73,14 +74,28 @@
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot access on non-current patch set");
     }
+    Address address = Address.tryParse(id.get());
 
-    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
-
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
     Collection<Account.Id> reviewers =
         approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
+    // See if the id exists as a reviewer for this change
     if (reviewers.contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
     }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
     throw new ResourceNotFoundException(id);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 409be9d..cf13f6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -36,9 +36,9 @@
 import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -62,7 +62,7 @@
   private Account newAssignee;
   private Account oldAssignee;
 
-  @AssistedInject
+  @Inject
   SetAssigneeOp(
       AccountsCollection accounts,
       ChangeMessagesUtil cmUtil,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 0e78c18..edd0873 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -39,8 +39,8 @@
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashSet;
@@ -64,7 +64,7 @@
   private Set<String> toRemove;
   private ImmutableSortedSet<String> updatedHashtags;
 
-  @AssistedInject
+  @Inject
   SetHashtagsOp(
       NotesMigration notesMigration,
       ChangeMessagesUtil cmUtil,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
new file mode 100644
index 0000000..dde4a9d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+
+class SetPrivateOp implements BatchUpdateOp {
+  private final boolean isPrivate;
+
+  SetPrivateOp(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) {
+    Change change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setPrivate(isPrivate);
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setPrivate(isPrivate);
+    return true;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
index 6cdb5e56..7b93277 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.config;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.gwtjsonrpc.server.XsrfException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 7b110e3..7747437 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -92,6 +92,7 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
@@ -228,6 +229,7 @@
     install(new AccessControlModule());
     install(new CmdLineParserModule());
     install(new EmailModule());
+    install(new ExternalIdModule());
     install(new GitModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
index 33e68d3..a2e0356 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.util.ServerRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 
 /**
  * Provider of the group(s) which should become owners of a newly created project. The only matching
@@ -40,7 +40,7 @@
     ProjectOwnerGroupsProvider create(Project.NameKey project);
   }
 
-  @AssistedInject
+  @Inject
   public ProjectOwnerGroupsProvider(
       GroupBackend gb,
       ThreadLocalRequestContext context,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
index 3987aed..c6384ae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
@@ -51,12 +51,9 @@
         cleanup
             .get()
             .add(
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    c.close();
-                    db = null;
-                  }
+                () -> {
+                  c.close();
+                  db = null;
                 });
       } catch (Throwable e) {
         c.close();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
index 99b647a..c5376fd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
@@ -34,8 +34,8 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -64,7 +64,7 @@
         @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify);
   }
 
-  @AssistedInject
+  @Inject
   AbandonOp(
       AbandonedSender.Factory abandonedSenderFactory,
       ChangeMessagesUtil cmUtil,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
index e680ea7..46916c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.server.util.SubmoduleSectionParser;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -55,7 +55,7 @@
   private final RequestId submissionId;
   Set<SubmoduleSubscription> subscriptions;
 
-  @AssistedInject
+  @Inject
   GitModules(
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted Branch.NameKey branch,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
index a7c8b53..9439a8b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -34,9 +34,9 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.concurrent.ExecutorService;
@@ -74,7 +74,7 @@
   private PatchSet patchSet;
   private PatchSetInfo info;
 
-  @AssistedInject
+  @Inject
   MergedByPushOp(
       PatchSetInfoFactory patchSetInfoFactory,
       ChangeMessagesUtil cmUtil,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
index e3b1ad6..b057c92 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
@@ -22,7 +22,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -185,7 +184,7 @@
   private boolean closeRepository;
   private IdentifiedUser author;
 
-  @AssistedInject
+  @Inject
   public MetaDataUpdate(
       GitReferenceUpdated gitRefUpdated,
       @Assisted Project.NameKey projectName,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
index 61d8cfe..e3d45ac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -155,6 +155,9 @@
       ImmutableSet.of(
           "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
 
+  private static final String REVIEWER = "reviewer";
+  private static final String KEY_ENABLE_REVIEWER_BY_EMAIL = "enableByEmail";
+
   private static final String LEGACY_PERMISSION_PUSH_TAG = "pushTag";
   private static final String LEGACY_PERMISSION_PUSH_SIGNED_TAG = "pushSignedTag";
 
@@ -182,6 +185,7 @@
   private boolean checkReceivedObjects;
   private Set<String> sectionsWithUnknownPermissions;
   private boolean hasLegacyPermissions;
+  private boolean enableReviewerByEmail;
 
   public static ProjectConfig read(MetaDataUpdate update)
       throws IOException, ConfigInvalidException {
@@ -435,6 +439,16 @@
     return checkReceivedObjects;
   }
 
+  /** @return the enableReviewerByEmail for this project, default is false. */
+  public boolean getEnableReviewerByEmail() {
+    return enableReviewerByEmail;
+  }
+
+  /** Set enableReviewerByEmail for this project, default is false. */
+  public void setEnableReviewerByEmail(boolean val) {
+    enableReviewerByEmail = val;
+  }
+
   /**
    * Check all GroupReferences use current group name, repairing stale ones.
    *
@@ -526,6 +540,7 @@
     mimeTypes = new ConfiguredMimeTypes(projectName.get(), rc);
     loadPluginSections(rc);
     loadReceiveSection(rc);
+    loadReviewerSection(rc);
   }
 
   private void loadAccountsSection(Config rc, Map<String, GroupReference> groupsByName) {
@@ -933,6 +948,10 @@
     maxObjectSizeLimit = rc.getLong(RECEIVE, null, KEY_MAX_OBJECT_SIZE_LIMIT, 0);
   }
 
+  private void loadReviewerSection(Config rc) {
+    enableReviewerByEmail = rc.getBoolean(REVIEWER, null, KEY_ENABLE_REVIEWER_BY_EMAIL, false);
+  }
+
   private void loadPluginSections(Config rc) {
     pluginConfigs = new HashMap<>();
     for (String plugin : rc.getSubsections(PLUGIN)) {
@@ -1067,6 +1086,7 @@
     saveAccessSections(rc, keepGroups);
     saveNotifySections(rc, keepGroups);
     savePluginSections(rc, keepGroups);
+    saveReviewerSection(rc);
     groupList.retainUUIDs(keepGroups);
     saveLabelSections(rc);
     saveSubscribeSections(rc);
@@ -1288,40 +1308,55 @@
 
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_ALLOW_POST_SUBMIT,
           label.allowPostSubmit(),
           LabelType.DEF_ALLOW_POST_SUBMIT);
       setBooleanConfigKey(
-          rc, name, KEY_COPY_MIN_SCORE, label.isCopyMinScore(), LabelType.DEF_COPY_MIN_SCORE);
-      setBooleanConfigKey(
-          rc, name, KEY_COPY_MAX_SCORE, label.isCopyMaxScore(), LabelType.DEF_COPY_MAX_SCORE);
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_MIN_SCORE,
+          label.isCopyMinScore(),
+          LabelType.DEF_COPY_MIN_SCORE);
       setBooleanConfigKey(
           rc,
+          LABEL,
+          name,
+          KEY_COPY_MAX_SCORE,
+          label.isCopyMaxScore(),
+          LabelType.DEF_COPY_MAX_SCORE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
           label.isCopyAllScoresOnTrivialRebase(),
           LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
           label.isCopyAllScoresIfNoCodeChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
           label.isCopyAllScoresIfNoChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
       setBooleanConfigKey(
-          rc, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
+          rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = Lists.newArrayListWithCapacity(label.getValues().size());
       for (LabelValue value : label.getValues()) {
         values.add(value.format());
@@ -1335,11 +1370,11 @@
   }
 
   private static void setBooleanConfigKey(
-      Config rc, String name, String key, boolean value, boolean defaultValue) {
+      Config rc, String section, String name, String key, boolean value, boolean defaultValue) {
     if (value == defaultValue) {
-      rc.unset(LABEL, name, key);
+      rc.unset(section, name, key);
     } else {
-      rc.setBoolean(LABEL, name, key, value);
+      rc.setBoolean(section, name, key, value);
     }
   }
 
@@ -1367,6 +1402,11 @@
     }
   }
 
+  private void saveReviewerSection(Config rc) {
+    setBooleanConfigKey(
+        rc, REVIEWER, null, KEY_ENABLE_REVIEWER_BY_EMAIL, enableReviewerByEmail, false);
+  }
+
   private void saveGroupList() throws IOException {
     saveUTF8(GroupList.FILE_NAME, groupList.asText());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 3198017..1a5f14e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -994,7 +994,7 @@
           break;
 
         default:
-          reject(cmd);
+          reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
           continue;
       }
 
@@ -1113,7 +1113,10 @@
             break;
 
           default:
-            reject(cmd);
+            reject(
+                cmd,
+                "prohibited by Gerrit: don't know how to handle config update of type "
+                    + cmd.getType());
             continue;
         }
       }
@@ -1145,7 +1148,7 @@
       validateNewCommits(ctl, cmd);
       batch.addCommand(cmd);
     } else {
-      reject(cmd);
+      reject(cmd, "prohibited by Gerrit: create access denied for " + cmd.getRefName());
     }
   }
 
@@ -1168,7 +1171,7 @@
       } else {
         errors.put(Error.UPDATE, ctl.getRefName());
       }
-      reject(cmd);
+      reject(cmd, "prohibited by Gerrit: ref update access denied");
     }
   }
 
@@ -1269,6 +1272,12 @@
     @Option(name = "--draft", usage = "mark new/updated changes as draft")
     boolean draft;
 
+    @Option(name = "--private", usage = "mark new/updated change as private")
+    boolean isPrivate;
+
+    @Option(name = "--remove-private", usage = "remove privacy flag from updated change")
+    boolean removePrivate;
+
     @Option(
       name = "--edit",
       aliases = {"-e"},
@@ -1522,6 +1531,11 @@
       return;
     }
 
+    if (magicBranch.isPrivate && magicBranch.removePrivate) {
+      reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
+      return;
+    }
+
     if (magicBranch.draft && magicBranch.submit) {
       reject(cmd, "cannot submit draft");
       return;
@@ -1754,6 +1768,7 @@
           break;
         }
         total++;
+        rp.getRevWalk().parseBody(c);
         String name = c.name();
         groupCollector.visit(c);
         Collection<Ref> existingRefs = existing.get(c);
@@ -2130,6 +2145,7 @@
           changeInserterFactory
               .create(changeId, commit, refName)
               .setTopic(magicBranch.topic)
+              .setPrivate(magicBranch.isPrivate)
               // Changes already validated in validateNewCommits.
               .setValidatePolicy(CommitValidators.Policy.NONE);
 
@@ -2878,10 +2894,6 @@
     return r;
   }
 
-  private void reject(ReceiveCommand cmd) {
-    reject(cmd, "prohibited by Gerrit");
-  }
-
   private void reject(ReceiveCommand cmd, String why) {
     cmd.setResult(REJECTED_OTHER_REASON, why);
     commandProgress.update(1);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
index 6ac5da1..27dea6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
@@ -55,8 +55,8 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.util.HashMap;
@@ -134,7 +134,7 @@
   private RequestScopePropagator requestScopePropagator;
   private boolean updateRef;
 
-  @AssistedInject
+  @Inject
   ReplaceOp(
       AccountResolver accountResolver,
       ApprovalCopier approvalCopier,
@@ -240,6 +240,13 @@
       if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
         update.setTopic(magicBranch.topic);
       }
+      if (magicBranch.removePrivate) {
+        change.setPrivate(false);
+        update.setPrivate(false);
+      } else if (magicBranch.isPrivate) {
+        change.setPrivate(true);
+        update.setPrivate(true);
+      }
     }
 
     boolean draft = magicBranch != null && magicBranch.draft;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
index 56c0c44..7c236e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
@@ -35,8 +35,8 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
@@ -117,7 +117,7 @@
   // map of superproject and its branches which has submodule subscriptions
   private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
 
-  @AssistedInject
+  @Inject
   public SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
       @GerritPersonIdent PersonIdent myIdent,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
index 9d8d1ac..536858b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -281,12 +281,7 @@
           return;
         }
 
-        // Reuse tree from parent commit unless there are contents in newTree or
-        // there is no tree for a parent commit.
-        ObjectId res =
-            newTree.getEntryCount() != 0 || srcTree == null
-                ? newTree.writeTree(inserter)
-                : srcTree.copy();
+        ObjectId res = newTree.writeTree(inserter);
         if (res.equals(srcTree) && !update.allowEmpty() && (commit.getTreeId() == null)) {
           // If there are no changes to the content, don't create the commit.
           return;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
index 9262e63..e5c253d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CommitMergeStatus.java
@@ -62,7 +62,7 @@
           + "\n"
           + "Please rebase the change locally and upload again for review.");
 
-  private String message;
+  private final String message;
 
   CommitMergeStatus(String message) {
     this.message = message;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
index f721978..4134623 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
@@ -53,9 +53,9 @@
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.RequestId;
+import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -145,7 +145,7 @@
     final MergeUtil mergeUtil;
     final boolean dryrun;
 
-    @AssistedInject
+    @Inject
     Arguments(
         AccountCache accountCache,
         ApprovalsUtil approvalsUtil,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
index 24ff379..07f3b21 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
@@ -14,30 +14,30 @@
 
 package com.google.gerrit.server.git.validators;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.validators.ValidationException;
-import java.util.Collections;
 import java.util.List;
 
 public class CommitValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
-  private final List<CommitValidationMessage> messages;
+  private final ImmutableList<CommitValidationMessage> messages;
 
   public CommitValidationException(String reason, List<CommitValidationMessage> messages) {
     super(reason);
-    this.messages = messages;
+    this.messages = ImmutableList.copyOf(messages);
   }
 
   public CommitValidationException(String reason) {
     super(reason);
-    this.messages = Collections.emptyList();
+    this.messages = ImmutableList.of();
   }
 
   public CommitValidationException(String reason, Throwable why) {
     super(reason, why);
-    this.messages = Collections.emptyList();
+    this.messages = ImmutableList.of();
   }
 
-  public List<CommitValidationMessage> getMessages() {
+  public ImmutableList<CommitValidationMessage> getMessages() {
     return messages;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 3641076..80792c3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -39,7 +39,7 @@
 
   public static ReceiveCommand getCommand(RefUpdate update, ReceiveCommand.Type type) {
     return new ReceiveCommand(
-        update.getOldObjectId(), update.getNewObjectId(), update.getName(), type);
+        update.getExpectedOldObjectId(), update.getNewObjectId(), update.getName(), type);
   }
 
   private final RefReceivedEvent event;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
index 96aec3f..7590aea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -24,7 +24,7 @@
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.SchemaUtil;
 import java.sql.Timestamp;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index b8acadc..d7fe713 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.index.FieldDef;
@@ -53,6 +54,7 @@
 import com.google.gerrit.server.index.SchemaUtil;
 import com.google.gerrit.server.index.change.StalenessChecker.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -184,6 +186,12 @@
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
       exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
 
+  /** Reviewer(s) associated with the change that do not have a gerrit account. */
+  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
+      exact("reviewer_by_email")
+          .stored()
+          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()));
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
@@ -200,6 +208,27 @@
     return state.toString() + ',' + id;
   }
 
+  @VisibleForTesting
+  static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
+    List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
+    for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c :
+        reviewersByEmail.asTable().cellSet()) {
+      String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
+      r.add(v);
+      if (c.getColumnKey().getName() != null) {
+        // Add another entry without the name to provide search functionality on the email
+        Address emailOnly = new Address(c.getColumnKey().getEmail());
+        r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
+      }
+      r.add(v + ',' + c.getValue().getTime());
+    }
+    return r;
+  }
+
+  public static String getReviewerByEmailFieldValue(ReviewerStateInternal state, Address adr) {
+    return state.toString() + ',' + adr;
+  }
+
   public static ReviewerSet parseReviewerFieldValues(Iterable<String> values) {
     ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
         ImmutableTable.builder();
@@ -220,6 +249,25 @@
     return ReviewerSet.fromTable(b.build());
   }
 
+  public static ReviewerByEmailSet parseReviewerByEmailFieldValues(Iterable<String> values) {
+    ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
+    for (String v : values) {
+      int f = v.indexOf(',');
+      if (f < 0) {
+        continue;
+      }
+      int l = v.lastIndexOf(',');
+      if (l == f) {
+        continue;
+      }
+      b.put(
+          ReviewerStateInternal.valueOf(v.substring(0, f)),
+          Address.parse(v.substring(f + 1, l)),
+          new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
+    }
+    return ReviewerByEmailSet.fromTable(b.build());
+  }
+
   /** Commit ID of any patch set on the change, using prefix match. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
       prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
@@ -385,6 +433,10 @@
       intRange(ChangeQueryBuilder.FIELD_DELTA)
           .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
 
+  /** Determines if this change is private. */
+  public static final FieldDef<ChangeData, String> PRIVATE =
+      exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
+
   /** Users who have commented on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
       integer(ChangeQueryBuilder.FIELD_COMMENTBY)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index d988612..ba7c1ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -90,7 +90,11 @@
   @Deprecated
   static final Schema<ChangeData> V38 = schema(V37, ChangeField.UNRESOLVED_COMMENT_COUNT);
 
-  static final Schema<ChangeData> V39 = schema(V38);
+  @Deprecated static final Schema<ChangeData> V39 = schema(V38);
+
+  @Deprecated static final Schema<ChangeData> V40 = schema(V39, ChangeField.PRIVATE);
+
+  static final Schema<ChangeData> V41 = schema(V40, ChangeField.REVIEWER_BY_EMAIL);
 
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
index f3b08fb..7f3ac01 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
@@ -42,6 +42,14 @@
     throw new IllegalArgumentException("Invalid email address: " + in);
   }
 
+  public static Address tryParse(String in) {
+    try {
+      return parse(in);
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
   final String name;
   final String email;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index bc09488..18fc083 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -180,6 +180,18 @@
     setHeader("X-Gerrit-Change-Number", "" + change.getChangeId());
     setChangeUrlHeader();
     setCommitIdHeader();
+
+    if (notify.ordinal() >= NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+      try {
+        addByEmail(
+            RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
+        addByEmail(
+            RecipientType.TO,
+            changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+      } catch (OrmException e) {
+        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
+      }
+    }
   }
 
   private void setChangeUrlHeader() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index 3e9e62c..2c6e655 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -50,7 +50,7 @@
     boolean isDraft = change.getStatus() == Change.Status.DRAFT;
     try {
       // Try to mark interested owners with TO and CC or BCC line.
-      Watchers matching = getWatchers(NotifyType.NEW_CHANGES, !isDraft);
+      Watchers matching = getWatchers(NotifyType.NEW_CHANGES, !isDraft && !change.isPrivate());
       for (Account.Id user :
           Iterables.concat(matching.to.accounts, matching.cc.accounts, matching.bcc.accounts)) {
         if (isOwnerOfProjectOrBranch(user)) {
@@ -69,7 +69,7 @@
       log.warn("Cannot notify watchers for new change", err);
     }
 
-    includeWatchers(NotifyType.NEW_PATCHSETS, !isDraft);
+    includeWatchers(NotifyType.NEW_PATCHSETS, !isDraft && !change.isPrivate());
   }
 
   private boolean isOwnerOfProjectOrBranch(Account.Id user) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index a563846..0fea7ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -32,6 +33,7 @@
 /** Let users know that a reviewer and possibly her review have been removed. */
 public class DeleteReviewerSender extends ReplyToChangeSender {
   private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
 
   public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
     @Override
@@ -49,6 +51,10 @@
     reviewers.addAll(cc);
   }
 
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -58,6 +64,7 @@
     ccExistingReviewers();
     includeWatchers(NotifyType.ALL_COMMENTS);
     add(RecipientType.TO, reviewers);
+    addByEmail(RecipientType.TO, reviewersByEmail);
     removeUsersThatIgnoredTheChange();
   }
 
@@ -70,13 +77,16 @@
   }
 
   public List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
+    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
       return null;
     }
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       names.add(getNameFor(id));
     }
+    for (Address a : reviewersByEmail) {
+      names.add(a.toString());
+    }
     return names;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index f1a9ad8..3f6d991 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
@@ -28,7 +29,9 @@
 /** Sends an email alerting a user to a new change for them to review. */
 public abstract class NewChangeSender extends ChangeEmail {
   private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
+  private final Set<Address> extraCCByEmail = new HashSet<>();
 
   protected NewChangeSender(EmailArguments ea, ChangeData cd) throws OrmException {
     super(ea, "newchange", cd);
@@ -38,10 +41,18 @@
     reviewers.addAll(cc);
   }
 
+  public void addReviewersByEmail(final Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
   public void addExtraCC(final Collection<Account.Id> cc) {
     extraCC.addAll(cc);
   }
 
+  public void addExtraCCByEmail(final Collection<Address> cc) {
+    extraCCByEmail.addAll(cc);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -55,9 +66,11 @@
       case ALL:
       default:
         add(RecipientType.CC, extraCC);
+        extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
         //$FALL-THROUGH$
       case OWNER_REVIEWERS:
         add(RecipientType.TO, reviewers);
+        addByEmail(RecipientType.TO, reviewersByEmail);
         break;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 730b710..6877879 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -441,6 +441,13 @@
     }
   }
 
+  /** Schedule this message for delivery to the listed address. */
+  protected void addByEmail(final RecipientType rt, final Collection<Address> list) {
+    for (final Address id : list) {
+      add(rt, id);
+    }
+  }
+
   protected void add(final RecipientType rt, final UserIdentity who) {
     if (who != null && who.getAccount() != null) {
       add(rt, who.getAccount());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index c90000f..0902d0b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -66,7 +66,8 @@
     add(RecipientType.CC, extraCC);
     rcptToAuthors(RecipientType.CC);
     bccStarredBy();
-    includeWatchers(NotifyType.NEW_PATCHSETS, !patchSet.isDraft());
+    removeUsersThatIgnoredTheChange();
+    includeWatchers(NotifyType.NEW_PATCHSETS, !patchSet.isDraft() && !change.isPrivate());
     removeUsersThatIgnoredTheChange();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
index d5b1b3d..cccf361 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -233,7 +233,7 @@
     // last time this file was updated.
     checkColumns(Change.Id.class, 1);
 
-    checkColumns(Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 101);
+    checkColumns(Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 20, 101);
     checkColumns(ChangeMessage.Key.class, 1, 2);
     checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
     checkColumns(PatchSet.Id.class, 1, 2);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index c848987..a989598 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -73,6 +73,7 @@
   public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
   public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
       new FooterKey("Patch-set-description");
+  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
   public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until");
   public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
   public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 4993a5d..d2889ae 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
@@ -428,6 +429,11 @@
     return state.reviewers();
   }
 
+  /** @return reviewers that do not currently have a Gerrit account and were added by email. */
+  public ReviewerByEmailSet getReviewersByEmail() {
+    return state.reviewersByEmail();
+  }
+
   public ImmutableList<ReviewerStatusUpdate> getReviewerUpdates() {
     return state.reviewerUpdates();
   }
@@ -563,6 +569,13 @@
     return state.readOnlyUntil();
   }
 
+  public boolean isPrivate() {
+    if (state.isPrivate() == null) {
+      return false;
+    }
+    return state.isPrivate();
+  }
+
   @Override
   protected void onLoad(LoadHandle handle)
       throws NoSuchChangeException, IOException, ConfigInvalidException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index dac999c..d6cccd7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
@@ -62,8 +63,10 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
@@ -127,6 +130,7 @@
   // Private final but mutable members initialized in the constructor and filled
   // in during the parsing process.
   private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
+  private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -157,6 +161,7 @@
   private String tag;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
   private Timestamp readOnlyUntil;
+  private Boolean isPrivate;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -172,6 +177,7 @@
     approvals = new LinkedHashMap<>();
     bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
+    reviewersByEmail = HashBasedTable.create();
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
@@ -199,6 +205,7 @@
       parseNotes();
       allPastReviewers.addAll(reviewers.rowKeySet());
       pruneReviewers();
+      pruneReviewersByEmail();
 
       updatePatchSetStates();
       checkMandatoryFooters();
@@ -232,13 +239,15 @@
         patchSets,
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
+        ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)),
         allPastReviewers,
         buildReviewerUpdates(),
         submitRecords,
         buildAllMessages(),
         buildMessagesByPatchSet(),
         comments,
-        readOnlyUntil);
+        readOnlyUntil,
+        isPrivate);
   }
 
   private PatchSet.Id buildCurrentPatchSetId() {
@@ -371,6 +380,9 @@
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
         parseReviewer(ts, state, line);
       }
+      for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
+        parseReviewerByEmail(ts, state, line);
+      }
       // Don't update timestamp when a reviewer was added, matching RevewDb
       // behavior.
     }
@@ -379,6 +391,10 @@
       parseReadOnlyUntil(commit);
     }
 
+    if (isPrivate == null) {
+      parseIsPrivate(commit);
+    }
+
     if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
       lastUpdatedOn = ts;
     }
@@ -910,6 +926,19 @@
     }
   }
 
+  private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
+      throws ConfigInvalidException {
+    Address adr;
+    try {
+      adr = Address.parse(line);
+    } catch (IllegalArgumentException e) {
+      throw invalidFooter(state.getByEmailFooterKey(), line);
+    }
+    if (!reviewersByEmail.containsRow(adr)) {
+      reviewersByEmail.put(adr, state, ts);
+    }
+  }
+
   private void parseReadOnlyUntil(ChangeNotesCommit commit) throws ConfigInvalidException {
     String raw = parseOneFooter(commit, FOOTER_READ_ONLY_UNTIL);
     if (raw == null) {
@@ -924,6 +953,20 @@
     }
   }
 
+  private void parseIsPrivate(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String raw = parseOneFooter(commit, FOOTER_PRIVATE);
+    if (raw == null) {
+      return;
+    } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
+      isPrivate = true;
+      return;
+    } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
+      isPrivate = false;
+      return;
+    }
+    throw invalidFooter(FOOTER_PRIVATE, raw);
+  }
+
   private void pruneReviewers() {
     Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
         reviewers.cellSet().iterator();
@@ -935,6 +978,17 @@
     }
   }
 
+  private void pruneReviewersByEmail() {
+    Iterator<Table.Cell<Address, ReviewerStateInternal, Timestamp>> rit =
+        reviewersByEmail.cellSet().iterator();
+    while (rit.hasNext()) {
+      Table.Cell<Address, ReviewerStateInternal, Timestamp> e = rit.next();
+      if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
+        rit.remove();
+      }
+    }
+  }
+
   private void updatePatchSetStates() {
     Set<PatchSet.Id> missing = new TreeSet<>(ReviewDbUtil.intKeyOrdering());
     for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext(); ) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 7b25bbd..eee1a34 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
@@ -65,12 +66,14 @@
         ImmutableList.of(),
         ImmutableList.of(),
         ReviewerSet.empty(),
+        ReviewerByEmailSet.empty(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableListMultimap.of(),
         ImmutableListMultimap.of(),
+        null,
         null);
   }
 
@@ -94,13 +97,15 @@
       Map<PatchSet.Id, PatchSet> patchSets,
       ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
       ReviewerSet reviewers,
+      ReviewerByEmailSet reviewersByEmail,
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> allChangeMessages,
       ListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet,
       ListMultimap<RevId, Comment> publishedComments,
-      @Nullable Timestamp readOnlyUntil) {
+      @Nullable Timestamp readOnlyUntil,
+      @Nullable Boolean isPrivate) {
     if (hashtags == null) {
       hashtags = ImmutableSet.of();
     }
@@ -119,19 +124,22 @@
             originalSubject,
             submissionId,
             assignee,
-            status),
+            status,
+            isPrivate),
         ImmutableSet.copyOf(pastAssignees),
         ImmutableSet.copyOf(hashtags),
         ImmutableList.copyOf(patchSets.entrySet()),
         ImmutableList.copyOf(approvals.entries()),
         reviewers,
+        reviewersByEmail,
         ImmutableList.copyOf(allPastReviewers),
         ImmutableList.copyOf(reviewerUpdates),
         ImmutableList.copyOf(submitRecords),
         ImmutableList.copyOf(allChangeMessages),
         ImmutableListMultimap.copyOf(changeMessagesByPatchSet),
         ImmutableListMultimap.copyOf(publishedComments),
-        readOnlyUntil);
+        readOnlyUntil,
+        isPrivate);
   }
 
   /**
@@ -174,6 +182,9 @@
     // TODO(dborowitz): Use a sensible default other than null
     @Nullable
     abstract Change.Status status();
+
+    @Nullable
+    abstract Boolean isPrivate();
   }
 
   // Only null if NoteDb is disabled.
@@ -197,6 +208,8 @@
 
   abstract ReviewerSet reviewers();
 
+  abstract ReviewerByEmailSet reviewersByEmail();
+
   abstract ImmutableList<Account.Id> allPastReviewers();
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
@@ -212,6 +225,9 @@
   @Nullable
   abstract Timestamp readOnlyUntil();
 
+  @Nullable
+  abstract Boolean isPrivate();
+
   Change newChange(Project.NameKey project) {
     ChangeColumns c = checkNotNull(columns(), "columns are required");
     Change change =
@@ -269,6 +285,7 @@
     change.setLastUpdatedOn(c.lastUpdatedOn());
     change.setSubmissionId(c.submissionId());
     change.setAssignee(c.assignee());
+    change.setPrivate(c.isPrivate() == null ? false : c.isPrivate());
 
     if (!patchSets().isEmpty()) {
       change.setCurrentPatchSet(c.currentPatchSetId(), c.subject(), c.originalSubject());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 7af0cb4..f699d76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -29,6 +29,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
@@ -60,6 +61,7 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
@@ -127,6 +129,7 @@
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
+  private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<Comment> comments = new ArrayList<>();
 
   private String commitSubject;
@@ -149,6 +152,7 @@
   private String psDescription;
   private boolean currentPatchSet;
   private Timestamp readOnlyUntil;
+  private Boolean isPrivate;
 
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
@@ -469,6 +473,15 @@
     reviewers.put(reviewer, ReviewerStateInternal.REMOVED);
   }
 
+  public void putReviewerByEmail(Address reviewer, ReviewerStateInternal type) {
+    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
+    reviewersByEmail.put(reviewer, type);
+  }
+
+  public void removeReviewerByEmail(Address reviewer) {
+    reviewersByEmail.put(reviewer, ReviewerStateInternal.REMOVED);
+  }
+
   public void setPatchSetState(PatchSetState psState) {
     this.psState = psState;
   }
@@ -658,6 +671,10 @@
       addIdent(msg, e.getKey()).append('\n');
     }
 
+    for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
+      addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
+    }
+
     for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
       addFooter(msg, FOOTER_LABEL);
       // Label names/values are safe to append without sanitizing.
@@ -711,6 +728,10 @@
       addFooter(msg, FOOTER_READ_ONLY_UNTIL, ChangeNoteUtil.formatTime(serverIdent, readOnlyUntil));
     }
 
+    if (isPrivate != null) {
+      addFooter(msg, FOOTER_PRIVATE, isPrivate);
+    }
+
     cb.setMessage(msg.toString());
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
@@ -743,6 +764,7 @@
         && changeMessage == null
         && comments.isEmpty()
         && reviewers.isEmpty()
+        && reviewersByEmail.isEmpty()
         && changeId == null
         && branch == null
         && status == null
@@ -757,7 +779,8 @@
         && tag == null
         && psDescription == null
         && !currentPatchSet
-        && readOnlyUntil == null;
+        && readOnlyUntil == null
+        && isPrivate == null;
   }
 
   ChangeDraftUpdate getDraftUpdate() {
@@ -777,6 +800,10 @@
     return isAllowWriteToNewtRef;
   }
 
+  public void setPrivate(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
   void setReadOnlyUntil(Timestamp readOnlyUntil) {
     this.readOnlyUntil = readOnlyUntil;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index b7bcafa..ec4899a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -43,9 +43,9 @@
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gwtorm.server.OrmConcurrencyException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
@@ -206,7 +206,7 @@
   private String refLogMessage;
   private PersonIdent refLogIdent;
 
-  @AssistedInject
+  @Inject
   NoteDbUpdateManager(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       GitRepositoryManager repoManager,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
index 0101cd7..0b097d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -52,6 +54,7 @@
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
  * Class for managing an incrementing sequence backed by a git repository.
@@ -117,7 +120,15 @@
       Retryer<RefUpdate.Result> retryer) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
     this.projectName = checkNotNull(projectName, "projectName");
-    this.refName = RefNames.REFS_SEQUENCES + checkNotNull(name, "name");
+
+    checkArgument(
+        name != null
+            && !name.startsWith(REFS)
+            && !name.startsWith(REFS_SEQUENCES.substring(REFS.length())),
+        "name should be a suffix to follow \"refs/sequences/\", got: %s",
+        name);
+    this.refName = RefNames.REFS_SEQUENCES + name;
+
     this.seed = checkNotNull(seed, "seed");
 
     checkArgument(batchSize > 0, "expected batchSize > 0, got: %s", batchSize);
@@ -265,4 +276,10 @@
     ru.setForceUpdate(true); // Required for non-commitish updates.
     return ru.update(rw);
   }
+
+  public static ReceiveCommand storeNew(ObjectInserter ins, String name, int val)
+      throws IOException {
+    ObjectId newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
+    return new ReceiveCommand(ObjectId.zeroId(), newId, RefNames.REFS_SEQUENCES + name);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
index f250646..fad9832 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -29,13 +29,17 @@
   /** The user was previously a reviewer on the change, but was removed. */
   REMOVED(new FooterKey("Removed"), ReviewerState.REMOVED);
 
+  public static ReviewerStateInternal fromReviewerState(ReviewerState state) {
+    return ReviewerStateInternal.values()[state.ordinal()];
+  }
+
   static {
     boolean ok = true;
     if (ReviewerStateInternal.values().length != ReviewerState.values().length) {
       ok = false;
     }
-    for (ReviewerStateInternal s : ReviewerStateInternal.values()) {
-      ok &= s.name().equals(s.state.name());
+    for (int i = 0; i < ReviewerStateInternal.values().length; i++) {
+      ok &= ReviewerState.values()[i].equals(ReviewerStateInternal.values()[i].state);
     }
     if (!ok) {
       throw new IllegalStateException(
@@ -58,6 +62,10 @@
     return footerKey;
   }
 
+  FooterKey getByEmailFooterKey() {
+    return new FooterKey(footerKey.getName() + "-email");
+  }
+
   public ReviewerState asReviewerState() {
     return state;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index e6549f0..99d9615 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -44,7 +44,7 @@
   private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
   private ObjectId metaId;
 
-  @AssistedInject
+  @Inject
   RobotCommentNotes(Args args, @Assisted Change change) {
     super(args, change.getId(), PrimaryStorage.of(change), false);
     this.change = change;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index fa02691..188513f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -36,7 +36,7 @@
   private final DiffSummaryKey key;
   private final Project.NameKey project;
 
-  @AssistedInject
+  @Inject
   DiffSummaryLoader(PatchListCache plc, @Assisted DiffSummaryKey k, @Assisted Project.NameKey p) {
     patchListCache = plc;
     key = k;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
index a571c46..bf5b913 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -18,8 +18,8 @@
 import com.google.common.base.Throwables;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -52,7 +52,7 @@
   private final IntraLineDiffKey key;
   private final IntraLineDiffArgs args;
 
-  @AssistedInject
+  @Inject
   IntraLineLoader(
       @DiffExecutor ExecutorService diffExecutor,
       @GerritServerConfig Config cfg,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
index 124fe8e..682e613 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -86,7 +86,7 @@
   private final long timeoutMillis;
   private final boolean save;
 
-  @AssistedInject
+  @Inject
   PatchListLoader(
       GitRepositoryManager mgr,
       PatchListCache plc,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
index bf30d01..c6133ea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/AbstractPreloadedPluginScanner.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.server.plugins.Plugin.ApiType;
 import com.google.inject.Module;
+import com.google.inject.servlet.ServletModule;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.lang.annotation.Annotation;
@@ -116,7 +117,7 @@
   private void scanGuiceModules(Set<Class<?>> classes) throws IOException {
     try {
       Class<?> sysModuleBaseClass = Module.class;
-      Class<?> httpModuleBaseClass = Class.forName("com.google.inject.servlet.ServletModule");
+      Class<?> httpModuleBaseClass = ServletModule.class;
       Class<?> sshModuleBaseClass = Class.forName("com.google.gerrit.sshd.CommandModule");
       sshModuleClass = null;
       httpModuleClass = null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index ec114d8..27e64d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -200,6 +200,9 @@
 
   /** Can this user see this change? */
   public boolean isVisible(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+    if (getChange().isPrivate() && !isPrivateVisible(db, cd)) {
+      return false;
+    }
     if (getChange().getStatus() == Change.Status.DRAFT && !isDraftVisible(db, cd)) {
       return false;
     }
@@ -478,4 +481,11 @@
         || getRefControl().canViewDrafts()
         || getUser().isInternalUser();
   }
+
+  public boolean isPrivateVisible(ReviewDb db, ChangeData cd) throws OrmException {
+    return isOwner()
+        || isReviewer(db, cd)
+        || getRefControl().canViewPrivateChanges()
+        || getUser().isInternalUser();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
index 2f02728..feeea95 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
@@ -58,6 +58,7 @@
     InheritedBooleanInfo enableSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo requireSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo rejectImplicitMerges = new InheritedBooleanInfo();
+    InheritedBooleanInfo enableReviewerByEmail = new InheritedBooleanInfo();
 
     useContributorAgreements.value = projectState.isUseContributorAgreements();
     useSignedOffBy.value = projectState.isUseSignedOffBy();
@@ -73,6 +74,7 @@
     enableSignedPush.configuredValue = p.getEnableSignedPush();
     requireSignedPush.configuredValue = p.getRequireSignedPush();
     rejectImplicitMerges.configuredValue = p.getRejectImplicitMerges();
+    enableReviewerByEmail.configuredValue = p.getEnableReviewerByEmail();
 
     ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
     if (parentState != null) {
@@ -85,6 +87,7 @@
       enableSignedPush.inheritedValue = projectState.isEnableSignedPush();
       requireSignedPush.inheritedValue = projectState.isRequireSignedPush();
       rejectImplicitMerges.inheritedValue = projectState.isRejectImplicitMerges();
+      enableReviewerByEmail.inheritedValue = projectState.isEnableReviewerByEmail();
     }
 
     this.useContributorAgreements = useContributorAgreements;
@@ -93,6 +96,7 @@
     this.requireChangeId = requireChangeId;
     this.rejectImplicitMerges = rejectImplicitMerges;
     this.createNewChangeForAllNotInTarget = createNewChangeForAllNotInTarget;
+    this.enableReviewerByEmail = enableReviewerByEmail;
     if (serverEnableSignedPush) {
       this.enableSignedPush = enableSignedPush;
       this.requireSignedPush = requireSignedPush;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
index 77ff37a..373f561 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
@@ -26,9 +26,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -64,7 +64,7 @@
     DeleteRef create(ProjectResource r);
   }
 
-  @AssistedInject
+  @Inject
   DeleteRef(
       Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
@@ -115,8 +115,10 @@
     }
     RefUpdate.Result result;
     RefUpdate u = r.updateRef(ref);
+    u.setExpectedOldObjectId(r.exactRef(ref).getObjectId());
+    u.setNewObjectId(ObjectId.zeroId());
     u.setForceUpdate(true);
-    refDeletionValidator.validateRefOperation(ref, identifiedUser.get(), u);
+    refDeletionValidator.validateRefOperation(resource.getName(), identifiedUser.get(), u);
     int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
     for (; ; ) {
       try {
@@ -222,6 +224,8 @@
 
     RefUpdate u = r.updateRef(refName);
     u.setForceUpdate(true);
+    u.setExpectedOldObjectId(r.exactRef(refName).getObjectId());
+    u.setNewObjectId(ObjectId.zeroId());
     refDeletionValidator.validateRefOperation(project.getName(), identifiedUser.get(), u);
     return command;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
index d7af195..11f3805 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
@@ -24,6 +24,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.change.CherryPickCommit;
 
 public class Module extends RestApiModule {
   @Override
@@ -96,6 +97,8 @@
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
 
+    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
+
     factory(DeleteRef.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 5e0ba28..84fdf13 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -54,25 +54,19 @@
 
       log.info("Loading project cache");
       scheduler.execute(
-          new Runnable() {
-            @Override
-            public void run() {
-              for (final Project.NameKey name : cache.all()) {
-                pool.execute(
-                    new Runnable() {
-                      @Override
-                      public void run() {
-                        cache.get(name);
-                      }
-                    });
-              }
-              pool.shutdown();
-              try {
-                pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
-                log.info("Finished loading project cache");
-              } catch (InterruptedException e) {
-                log.warn("Interrupted while waiting for project cache to load");
-              }
+          () -> {
+            for (final Project.NameKey name : cache.all()) {
+              pool.execute(
+                  () -> {
+                    cache.get(name);
+                  });
+            }
+            pool.shutdown();
+            try {
+              pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
+              log.info("Finished loading project cache");
+            } catch (InterruptedException e) {
+              log.warn("Interrupted while waiting for project cache to load");
             }
           });
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index 8b8745e..32dc41f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -394,6 +394,10 @@
     return getInheritableBoolean(Project::getRejectImplicitMerges);
   }
 
+  public boolean isEnableReviewerByEmail() {
+    return getInheritableBoolean(Project::getEnableReviewerByEmail);
+  }
+
   public LabelTypes getLabelTypes() {
     Map<String, LabelType> types = new LinkedHashMap<>();
     for (ProjectState s : treeInOrder()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index 8413b5a9..4c751fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -407,6 +407,11 @@
     return canPerform(Permission.VIEW_DRAFTS);
   }
 
+  /** @return true if this user can view private changes. */
+  public boolean canViewPrivateChanges() {
+    return canPerform(Permission.VIEW_PRIVATE_CHANGES);
+  }
+
   /** @return true if this user can publish draft changes. */
   public boolean canPublishDrafts() {
     return canPerform(Permission.PUBLISH_DRAFTS);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
index 8a57c73..62144ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
@@ -163,8 +163,7 @@
 
   protected final Definition<T, ? extends QueryBuilder<T>> builderDef;
 
-  @SuppressWarnings("rawtypes")
-  protected final Map<String, OperatorFactory> opFactories;
+  protected final Map<String, OperatorFactory<?, ?>> opFactories;
 
   @SuppressWarnings({"unchecked", "rawtypes"})
   protected QueryBuilder(Definition<T, ? extends QueryBuilder<T>> def) {
@@ -296,12 +295,11 @@
     throw error("Unsupported query:" + value);
   }
 
-  @SuppressWarnings("unchecked")
-  private Predicate<T>[] children(final Tree r)
+  private List<Predicate<T>> children(final Tree r)
       throws QueryParseException, IllegalArgumentException {
-    final Predicate<T>[] p = new Predicate[r.getChildCount()];
-    for (int i = 0; i < p.length; i++) {
-      p[i] = toPredicate(r.getChild(i));
+    List<Predicate<T>> p = new ArrayList<>(r.getChildCount());
+    for (int i = 0; i < r.getChildCount(); i++) {
+      p.add(toPredicate(r.getChild(i)));
     }
     return p;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index c2b92aa..2c42502 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.query.InternalQuery;
@@ -68,10 +68,6 @@
     return query(AccountPredicates.defaultPredicate(query));
   }
 
-  public List<AccountState> byEmailPrefix(String emailPrefix) throws OrmException {
-    return query(AccountPredicates.email(emailPrefix));
-  }
-
   public List<AccountState> byExternalId(String scheme, String id) throws OrmException {
     return byExternalId(ExternalId.Key.create(scheme, id));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
similarity index 83%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index d998fa3..ad43c5f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class IsMergeablePredicate extends ChangeIndexPredicate {
+class BooleanPredicate extends ChangeIndexPredicate {
   private final FillArgs args;
 
-  IsMergeablePredicate(FillArgs args) {
-    super(ChangeField.MERGEABLE, "1");
+  BooleanPredicate(FieldDef<ChangeData, String> field, FillArgs args) {
+    super(field, "1");
     this.args = args;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index d94d496..1dbe5cd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
@@ -352,6 +353,7 @@
   private StarsOf starsOf;
   private ImmutableMap<Account.Id, StarRef> starRefs;
   private ReviewerSet reviewers;
+  private ReviewerByEmailSet reviewersByEmail;
   private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
   private PersonIdent committer;
@@ -954,6 +956,24 @@
     return reviewers;
   }
 
+  public ReviewerByEmailSet reviewersByEmail() throws OrmException {
+    if (reviewersByEmail == null) {
+      if (!lazyLoad) {
+        return ReviewerByEmailSet.empty();
+      }
+      reviewersByEmail = notes().getReviewersByEmail();
+    }
+    return reviewersByEmail;
+  }
+
+  public void setReviewersByEmail(ReviewerByEmailSet reviewersByEmail) {
+    this.reviewersByEmail = reviewersByEmail;
+  }
+
+  public ReviewerByEmailSet getReviewersByEmail() {
+    return reviewersByEmail;
+  }
+
   public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
     if (reviewerUpdates == null) {
       if (!lazyLoad) {
@@ -1063,6 +1083,8 @@
       }
       if (c.getStatus() == Change.Status.MERGED) {
         mergeable = true;
+      } else if (c.getStatus() == Change.Status.ABANDONED) {
+        return null;
       } else {
         if (!lazyLoad) {
           return null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index 0604f8b..0362c85 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 
 public abstract class ChangeIndexPredicate extends IndexPredicate<ChangeData>
     implements Matchable<ChangeData> {
@@ -27,4 +30,11 @@
   protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
+
+  protected static Predicate<ChangeData> create(Arguments args, Predicate<ChangeData> p) {
+    if (!args.allowsDrafts) {
+      return Predicate.and(p, Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
+    }
+    return p;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index af2cb60..6b66c41 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -61,8 +61,10 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ListChildProjects;
@@ -149,6 +151,7 @@
   public static final String FIELD_OWNERIN = "ownerin";
   public static final String FIELD_PARENTPROJECT = "parentproject";
   public static final String FIELD_PATH = "path";
+  public static final String FIELD_PRIVATE = "private";
   public static final String FIELD_PROJECT = "project";
   public static final String FIELD_PROJECTS = "projects";
   public static final String FIELD_REF = "ref";
@@ -569,7 +572,11 @@
     }
 
     if ("mergeable".equalsIgnoreCase(value)) {
-      return new IsMergeablePredicate(args.fillArgs);
+      return new BooleanPredicate(ChangeField.MERGEABLE, args.fillArgs);
+    }
+
+    if ("private".equalsIgnoreCase(value)) {
+      return new BooleanPredicate(ChangeField.PRIVATE, args.fillArgs);
     }
 
     if ("assigned".equalsIgnoreCase(value)) {
@@ -937,17 +944,12 @@
 
   @Operator
   public Predicate<ChangeData> reviewer(String who) throws QueryParseException, OrmException {
-    return Predicate.or(
-        parseAccount(who)
-            .stream()
-            .map(id -> ReviewerPredicate.reviewer(args, id))
-            .collect(toList()));
+    return reviewerByState(who, ReviewerStateInternal.REVIEWER);
   }
 
   @Operator
   public Predicate<ChangeData> cc(String who) throws QueryParseException, OrmException {
-    return Predicate.or(
-        parseAccount(who).stream().map(id -> ReviewerPredicate.cc(args, id)).collect(toList()));
+    return reviewerByState(who, ReviewerStateInternal.CC);
   }
 
   @Operator
@@ -1176,4 +1178,37 @@
   private Account.Id self() throws QueryParseException {
     return args.getIdentifiedUser().getAccountId();
   }
+
+  public Predicate<ChangeData> reviewerByState(String who, ReviewerStateInternal state)
+      throws QueryParseException, OrmException {
+    Predicate<ChangeData> reviewerByEmailPredicate = null;
+    if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
+      Address address = Address.tryParse(who);
+      if (address != null) {
+        reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(args, address, state);
+      }
+    }
+
+    Predicate<ChangeData> reviewerPredicate = null;
+    try {
+      reviewerPredicate =
+          Predicate.or(
+              parseAccount(who)
+                  .stream()
+                  .map(id -> ReviewerPredicate.forState(args, id, state))
+                  .collect(toList()));
+    } catch (QueryParseException e) {
+      // Propagate this exception only if we can't use 'who' to query by email
+      if (reviewerByEmailPredicate == null) {
+        throw e;
+      }
+    }
+
+    if (reviewerPredicate != null && reviewerByEmailPredicate != null) {
+      return Predicate.or(reviewerPredicate, reviewerByEmailPredicate);
+    } else if (reviewerPredicate != null) {
+      return reviewerPredicate;
+    }
+    return reviewerByEmailPredicate;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
new file mode 100644
index 0000000..a040e18
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
+import com.google.gwtorm.server.OrmException;
+
+class ReviewerByEmailPredicate extends ChangeIndexPredicate {
+
+  static Predicate<ChangeData> forState(Arguments args, Address adr, ReviewerStateInternal state) {
+    checkArgument(state != ReviewerStateInternal.REMOVED, "can't query by removed reviewer");
+    return create(args, new ReviewerByEmailPredicate(state, adr));
+  }
+
+  private final ReviewerStateInternal state;
+  private final Address adr;
+
+  private ReviewerByEmailPredicate(ReviewerStateInternal state, Address adr) {
+    super(ChangeField.REVIEWER_BY_EMAIL, ChangeField.getReviewerByEmailFieldValue(state, adr));
+    this.state = state;
+    this.adr = adr;
+  }
+
+  Address getAddress() {
+    return adr;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.reviewersByEmail().asTable().get(state, adr) != null;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 6ce02fb..5b86494 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.Predicate;
@@ -26,6 +26,12 @@
 import java.util.stream.Stream;
 
 class ReviewerPredicate extends ChangeIndexPredicate {
+  static Predicate<ChangeData> forState(
+      Arguments args, Account.Id id, ReviewerStateInternal state) {
+    checkArgument(state != ReviewerStateInternal.REMOVED, "can't query by removed reviewer");
+    return create(args, new ReviewerPredicate(state, id));
+  }
+
   static Predicate<ChangeData> reviewer(Arguments args, Account.Id id) {
     Predicate<ChangeData> p;
     if (args.notesMigration.readChanges()) {
@@ -54,15 +60,6 @@
             .collect(toList()));
   }
 
-  private static Predicate<ChangeData> create(Arguments args, Predicate<ChangeData> p) {
-    if (!args.allowsDrafts) {
-      // TODO(dborowitz): This really belongs much higher up e.g. QueryProcessor. Also, why are we
-      // even doing this?
-      return Predicate.and(p, Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
-    }
-    return p;
-  }
-
   private final ReviewerStateInternal state;
   private final Account.Id id;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 3f1d32c..9a56aa4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_SEQUENCES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -35,28 +37,39 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.inject.Inject;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** Creates the {@code All-Projects} repository and initial ACLs. */
 public class AllProjectsCreator {
   private final GitRepositoryManager mgr;
   private final AllProjectsName allProjectsName;
   private final PersonIdent serverUser;
+  private final NotesMigration notesMigration;
   private String message;
+  private int firstChangeId = ReviewDb.FIRST_CHANGE_ID;
 
   private GroupReference admin;
   private GroupReference batch;
@@ -69,10 +82,12 @@
       GitRepositoryManager mgr,
       AllProjectsName allProjectsName,
       SystemGroupBackend systemGroupBackend,
-      @GerritPersonIdent PersonIdent serverUser) {
+      @GerritPersonIdent PersonIdent serverUser,
+      NotesMigration notesMigration) {
     this.mgr = mgr;
     this.allProjectsName = allProjectsName;
     this.serverUser = serverUser;
+    this.notesMigration = notesMigration;
 
     this.anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS);
     this.registered = systemGroupBackend.getGroup(REGISTERED_USERS);
@@ -94,6 +109,12 @@
     return this;
   }
 
+  public AllProjectsCreator setFirstChangeIdForNoteDb(int id) {
+    checkArgument(id > 0, "id must be positive: %s", id);
+    firstChangeId = id;
+    return this;
+  }
+
   public void create() throws IOException, ConfigInvalidException {
     try (Repository git = mgr.openRepository(allProjectsName)) {
       initAllProjects(git);
@@ -112,8 +133,9 @@
   }
 
   private void initAllProjects(Repository git) throws IOException, ConfigInvalidException {
+    BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
     try (MetaDataUpdate md =
-        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git)) {
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git, bru)) {
       md.getCommitBuilder().setAuthor(serverUser);
       md.getCommitBuilder().setCommitter(serverUser);
       md.setMessage(
@@ -177,6 +199,8 @@
       grant(config, meta, Permission.SUBMIT, admin, owners);
 
       config.commitToNewRef(md, RefNames.REFS_CONFIG);
+      initSequences(git, bru);
+      execute(git, bru);
     }
   }
 
@@ -195,4 +219,27 @@
     c.getLabelSections().put(type.getName(), type);
     return type;
   }
+
+  private void initSequences(Repository git, BatchRefUpdate bru) throws IOException {
+    if (notesMigration.readChangeSequence()
+        && git.exactRef(REFS_SEQUENCES + Sequences.CHANGES) == null) {
+      // Can't easily reuse the inserter from MetaDataUpdate, but this shouldn't slow down site
+      // initialization unduly.
+      try (ObjectInserter ins = git.newObjectInserter()) {
+        bru.addCommand(RepoSequence.storeNew(ins, Sequences.CHANGES, firstChangeId));
+        ins.flush();
+      }
+    }
+  }
+
+  private void execute(Repository git, BatchRefUpdate bru) throws IOException {
+    try (RevWalk rw = new RevWalk(git)) {
+      bru.execute(rw, NullProgressMonitor.INSTANCE);
+    }
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        throw new IOException("Failed to initialize " + allProjectsName + " refs:\n" + bru);
+      }
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
index 65843d8..ee57c8b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceModule.java
@@ -25,6 +25,7 @@
     bind(DataSourceType.class).annotatedWith(Names.named("derby")).to(Derby.class);
     bind(DataSourceType.class).annotatedWith(Names.named("h2")).to(H2.class);
     bind(DataSourceType.class).annotatedWith(Names.named("jdbc")).to(JDBC.class);
+    bind(DataSourceType.class).annotatedWith(Names.named("mariadb")).to(MariaDb.class);
     bind(DataSourceType.class).annotatedWith(Names.named("mysql")).to(MySql.class);
     bind(DataSourceType.class).annotatedWith(Names.named("oracle")).to(Oracle.class);
     bind(DataSourceType.class).annotatedWith(Names.named("postgresql")).to(PostgreSQL.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 170a5fa..c5bd140 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -161,8 +161,8 @@
     }
   }
 
-  private void exportPoolMetrics(final BasicDataSource pool) {
-    final CallbackMetric1<Boolean, Integer> cnt =
+  private void exportPoolMetrics(BasicDataSource pool) {
+    CallbackMetric1<Boolean, Integer> cnt =
         metrics.newCallbackMetric(
             "sql/connection_pool/connections",
             Integer.class,
@@ -170,13 +170,10 @@
             Field.ofBoolean("active"));
     metrics.newTrigger(
         cnt,
-        new Runnable() {
-          @Override
-          public void run() {
-            synchronized (pool) {
-              cnt.set(true, pool.getNumActive());
-              cnt.set(false, pool.getNumIdle());
-            }
+        () -> {
+          synchronized (pool) {
+            cnt.set(true, pool.getNumActive());
+            cnt.set(false, pool.getNumIdle());
           }
         });
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java
new file mode 100644
index 0000000..ed18a86
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.schema.JdbcUtil.hostname;
+import static com.google.gerrit.server.schema.JdbcUtil.port;
+
+import com.google.gerrit.server.config.ConfigSection;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+
+class MariaDb extends BaseDataSourceType {
+  private final Config cfg;
+
+  @Inject
+  MariaDb(@GerritServerConfig Config cfg) {
+    super("org.mariadb.jdbc.Driver");
+    this.cfg = cfg;
+  }
+
+  @Override
+  public String getUrl() {
+    StringBuilder b = new StringBuilder();
+    ConfigSection dbs = new ConfigSection(cfg, "database");
+    b.append("jdbc:mariadb://");
+    b.append(hostname(dbs.optional("hostname")));
+    b.append(port(dbs.optional("port")));
+    b.append("/");
+    b.append(dbs.required("database"));
+    return b.toString();
+  }
+
+  @Override
+  public boolean usePool() {
+    // MariaDB has given us trouble with the connection pool,
+    // sometimes the backend disconnects and the pool winds
+    // up with a stale connection. Fortunately opening up
+    // a new MariaDB connection is usually very fast.
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index a67a8a9..caeba3a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -35,7 +35,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_142> C = Schema_142.class;
+  public static final Class<Schema_145> C = Schema_145.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java
new file mode 100644
index 0000000..b190b29
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Add isPrivate field to change. */
+public class Schema_143 extends SchemaVersion {
+  @Inject
+  Schema_143(Provider<Schema_142> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
new file mode 100644
index 0000000..70e55cf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class Schema_144 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Import external IDs from ReviewDb";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_144(
+      Provider<Schema_143> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    Set<ExternalId> toAdd = ExternalId.from(db.accountExternalIds().all().toList());
+    try {
+      try (Repository repo = repoManager.openRepository(allUsersName);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        ObjectId rev = ExternalIdReader.readRevision(repo);
+
+        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+        for (ExternalId extId : toAdd) {
+          ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
+        }
+
+        ExternalIdsUpdate.commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, serverIdent, serverIdent);
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Failed to migrate external IDs to NoteDb", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java
new file mode 100644
index 0000000..20acd32
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.schema.sql.SqlDialect;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+
+/** Create account_external_ids_byEmail index. */
+public class Schema_145 extends SchemaVersion {
+
+  @Inject
+  Schema_145(Provider<Schema_144> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    SqlDialect dialect = schema.getDialect();
+    try (StatementExecutor e = newExecutor(db)) {
+      dialect.dropIndex(e, "account_external_ids", "account_external_ids_byEmail");
+      e.execute(
+          "CREATE INDEX account_external_ids_byEmail"
+              + " ON account_external_ids"
+              + " (email_address)");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
index 2764bd8..ba4d93b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -18,18 +18,26 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -41,6 +49,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -117,7 +126,21 @@
     }
   }
 
-  protected static Order getOrder(Collection<? extends BatchUpdate> updates) {
+  static void setRequestIds(
+      Collection<? extends BatchUpdate> updates, @Nullable RequestId requestId) {
+    if (requestId != null) {
+      for (BatchUpdate u : updates) {
+        checkArgument(
+            u.requestId == null || u.requestId == requestId,
+            "refusing to overwrite RequestId %s in update with %s",
+            u.requestId,
+            requestId);
+        u.setRequestId(requestId);
+      }
+    }
+  }
+
+  static Order getOrder(Collection<? extends BatchUpdate> updates) {
     Order o = null;
     for (BatchUpdate u : updates) {
       if (o == null) {
@@ -129,7 +152,7 @@
     return o;
   }
 
-  protected static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
+  static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
     checkArgument(!updates.isEmpty());
     Boolean p = null;
     for (BatchUpdate u : updates) {
@@ -148,6 +171,28 @@
     return p;
   }
 
+  static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+    Throwables.throwIfUnchecked(e);
+
+    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
+    // ResourceConflictException to indicate an atomic update failure.
+    Throwables.throwIfInstanceOf(e, UpdateException.class);
+    Throwables.throwIfInstanceOf(e, RestApiException.class);
+
+    // Convert other common non-REST exception types with user-visible messages to corresponding
+    // REST exception types
+    if (e instanceof InvalidChangeOperationException) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } else if (e instanceof NoSuchChangeException
+        || e instanceof NoSuchRefException
+        || e instanceof NoSuchProjectException) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    }
+
+    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
+    throw new UpdateException(e);
+  }
+
   protected GitRepositoryManager repoManager;
 
   protected final Project.NameKey project;
@@ -198,7 +243,9 @@
   public abstract void execute(BatchUpdateListener listener)
       throws UpdateException, RestApiException;
 
-  public abstract void execute() throws UpdateException, RestApiException;
+  public void execute() throws UpdateException, RestApiException {
+    execute(BatchUpdateListener.NONE);
+  }
 
   protected abstract Context newContext();
 
@@ -251,6 +298,12 @@
     return user;
   }
 
+  protected Optional<Account> getAccount() {
+    return user.isIdentifiedUser()
+        ? Optional.of(user.asIdentifiedUser().getAccount())
+        : Optional.empty();
+  }
+
   protected Repository getRepository() throws IOException {
     initRepository();
     return repo;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
index d619490..ca39763 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
@@ -44,17 +44,24 @@
   ChangeUpdate getUpdate(PatchSet.Id psId);
 
   /**
-   * @return control for this change. The user will be the same as {@link #getUser()}, and the
-   *     change data is read within the same transaction that {@code updateChange} is executing.
+   * Get the control for this change, encapsulating the user and up-to-date change data.
+   *
+   * <p>The user will be the same as {@link #getUser()}, and the change data is read within the same
+   * transaction that {@link BatchUpdateOp#updateChange(ChangeContext)} is executing.
+   *
+   * @return control for this change.
    */
   ChangeControl getControl();
 
   /**
-   * @param bump whether to bump the value of {@link Change#getLastUpdatedOn()} field before storing
-   *     to ReviewDb. For NoteDb, the value is always incremented (assuming the update is not
-   *     otherwise a no-op).
+   * Don't bump the value of {@link Change#getLastUpdatedOn()}.
+   *
+   * <p>If called, don't bump the timestamp before storing to ReviewDb. Only has an effect in
+   * ReviewDb, and the only usage should be to match the behavior of NoteDb. Specifically, in NoteDb
+   * the timestamp is updated if and only if the change meta graph is updated, and is not updated
+   * when only drafts are modified.
    */
-  void bumpLastUpdatedOn(boolean bump);
+  void dontBumpLastUpdatedOn();
 
   /**
    * Instruct {@link BatchUpdate} to delete this change.
@@ -63,7 +70,11 @@
    */
   void deleteChange();
 
-  /** @return notes corresponding to {@link #getControl()}. */
+  /**
+   * Get notes corresponding to {@link #getControl()}.
+   *
+   * @return loaded notes instance.
+   */
   default ChangeNotes getNotes() {
     return checkNotNull(getControl().getNotes());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
index 497b7ab..db9239f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
@@ -33,7 +33,11 @@
  * <p>A single update may span multiple changes, but they all belong to a single repo.
  */
 public interface Context {
-  /** @return the project name this update operates on. */
+  /**
+   * Get the project name this update operates on.
+   *
+   * @return project.
+   */
   Project.NameKey getProject();
 
   /**
@@ -57,50 +61,80 @@
    */
   RevWalk getRevWalk() throws IOException;
 
-  /** @return the timestamp at which this update takes place. */
+  /**
+   * Get the timestamp at which this update takes place.
+   *
+   * @return timestamp.
+   */
   Timestamp getWhen();
 
   /**
-   * @return the time zone in which this update takes place. In the current implementation, this is
-   *     always the time zone of the server.
+   * Get the time zone in which this update takes place.
+   *
+   * <p>In the current implementation, this is always the time zone of the server.
+   *
+   * @return time zone.
    */
   TimeZone getTimeZone();
 
   /**
-   * @return an open ReviewDb database. Callers should not manage transactions or call mutating
-   *     methods on the Changes table. Mutations on other tables (including other entities in the
-   *     change entity group) are fine.
+   * Get the ReviewDb database.
+   *
+   * <p>Callers should not manage transactions or call mutating methods on the Changes table.
+   * Mutations on other tables (including other entities in the change entity group) are fine.
+   *
+   * @return open database instance.
    */
   ReviewDb getDb();
 
   /**
-   * @return user performing the update. In the current implementation, this is always an {@link
-   *     IdentifiedUser} or {@link com.google.gerrit.server.InternalUser}.
+   * Get the user performing the update.
+   *
+   * <p>In the current implementation, this is always an {@link IdentifiedUser} or {@link
+   * com.google.gerrit.server.InternalUser}.
+   *
+   * @return user.
    */
   CurrentUser getUser();
 
-  /** @return order in which operations are executed in this update. */
+  /**
+   * Get the order in which operations are executed in this update.
+   *
+   * @return order of operations.
+   */
   Order getOrder();
 
   /**
-   * @return identified user performing the update; throws an unchecked exception if the user is not
-   *     an {@link IdentifiedUser}
+   * Get the identified user performing the update.
+   *
+   * <p>Convenience method for {@code getUser().asIdentifiedUser()}.
+   *
+   * @see CurrentUser#asIdentifiedUser()
+   * @return user.
    */
   default IdentifiedUser getIdentifiedUser() {
     return checkNotNull(getUser()).asIdentifiedUser();
   }
 
   /**
-   * @return account of the user performing the update; throws if the user is not an {@link
-   *     IdentifiedUser}
+   * Get the account of the user performing the update.
+   *
+   * <p>Convenience method for {@code getIdentifiedUser().getAccount()}.
+   *
+   * @see CurrentUser#asIdentifiedUser()
+   * @return account.
    */
   default Account getAccount() {
     return getIdentifiedUser().getAccount();
   }
 
   /**
-   * @return account ID of the user performing the update; throws if the user is not an {@link
-   *     IdentifiedUser}
+   * Get the account ID of the user performing the update.
+   *
+   * <p>Convenience method for {@code getUser().getAccountId()}
+   *
+   * @see CurrentUser#getAccountId()
+   * @return account ID.
    */
   default Account.Id getAccountId() {
     return getIdentifiedUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
index 37a25af..abbc1d4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.update;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.Comparator.comparing;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Throwables;
@@ -30,7 +30,6 @@
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -59,17 +58,12 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -237,8 +231,8 @@
     }
 
     @Override
-    public void bumpLastUpdatedOn(boolean bump) {
-      bumpLastUpdatedOn = bump;
+    public void dontBumpLastUpdatedOn() {
+      bumpLastUpdatedOn = false;
     }
 
     @Override
@@ -272,16 +266,7 @@
     if (updates.isEmpty()) {
       return;
     }
-    if (requestId != null) {
-      for (BatchUpdate u : updates) {
-        checkArgument(
-            u.requestId == null || u.requestId == requestId,
-            "refusing to overwrite RequestId %s in update with %s",
-            u.requestId,
-            requestId);
-        u.setRequestId(requestId);
-      }
-    }
+    setRequestIds(updates, requestId);
     try {
       Order order = getOrder(updates);
       boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
@@ -318,45 +303,26 @@
           throw new IllegalStateException("invalid execution order: " + order);
       }
 
-      List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
-      for (ReviewDbBatchUpdate u : updates) {
-        indexFutures.addAll(u.indexFutures);
-      }
-      ChangeIndexer.allAsList(indexFutures).get();
+      ChangeIndexer.allAsList(
+              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
+          .get();
 
-      for (ReviewDbBatchUpdate u : updates) {
-        if (u.batchRefUpdate != null) {
-          // Fire ref update events only after all mutations are finished, since
-          // callers may assume a patch set ref being created means the change
-          // was created, or a branch advancing meaning some changes were
-          // closed.
-          u.gitRefUpdated.fire(
-              u.project,
-              u.batchRefUpdate,
-              u.getUser().isIdentifiedUser() ? u.getUser().asIdentifiedUser().getAccount() : null);
-        }
-      }
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
       if (!dryrun) {
         for (ReviewDbBatchUpdate u : updates) {
           u.executePostOps();
         }
       }
-    } catch (UpdateException | RestApiException e) {
-      // Propagate REST API exceptions thrown by operations; they commonly throw
-      // exceptions like ResourceConflictException to indicate an atomic update
-      // failure.
-      throw e;
-
-      // Convert other common non-REST exception types with user-visible
-      // messages to corresponding REST exception types
-    } catch (InvalidChangeOperationException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } catch (NoSuchChangeException | NoSuchRefException | NoSuchProjectException e) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-
     } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new UpdateException(e);
+      wrapAndThrowException(e);
     }
   }
 
@@ -375,7 +341,7 @@
   private final long skewMs;
   private final List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
 
-  @AssistedInject
+  @Inject
   ReviewDbBatchUpdate(
       @GerritServerConfig Config cfg,
       AllUsersName allUsers,
@@ -412,11 +378,6 @@
   }
 
   @Override
-  public void execute() throws UpdateException, RestApiException {
-    execute(BatchUpdateListener.NONE);
-  }
-
-  @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
     execute(ImmutableList.of(this), listener, requestId, false);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
index 9974bc6..181b5c7 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -133,12 +133,9 @@
     final AtomicInteger invocations = new AtomicInteger(0);
     metrics.newTrigger(
         cntr,
-        new Runnable() {
-          @Override
-          public void run() {
-            invocations.getAndIncrement();
-            cntr.set(42L);
-          }
+        () -> {
+          invocations.getAndIncrement();
+          cntr.set(42L);
         });
 
     // Triggers run immediately with DropWizard binding.
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
index ab68c10..2b7b78d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
@@ -105,7 +105,9 @@
                             + "  accepted = group Developers\n" //
                             + "  accepted = group Staff\n" //
                             + "  autoVerify = group Developers\n" //
-                            + "  agreementUrl = http://www.example.com/agree\n")) //
+                            + "  agreementUrl = http://www.example.com/agree\n" //
+                            + "[reviewer]\n" //
+                            + "  enableByEmail = true\n")) //
                 ));
 
     ProjectConfig cfg = read(rev);
@@ -132,6 +134,8 @@
     assertThat(submit.getExclusiveGroup()).isTrue();
     assertThat(read.getExclusiveGroup()).isTrue();
     assertThat(push.getExclusiveGroup()).isFalse();
+
+    assertThat(cfg.getEnableReviewerByEmail()).isTrue();
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 9d6cb60..5a1d10c 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.TestChanges;
@@ -751,7 +752,7 @@
     try (RevWalk walk = new RevWalk(repo)) {
       RevCommit commit = walk.parseCommit(update.getResult());
       walk.parseBody(commit);
-      assertThat(commit.getFullMessage()).endsWith("Hashtags: tag1,tag2\n");
+      assertThat(commit.getFullMessage()).contains("Hashtags: tag1,tag2\n");
     }
   }
 
@@ -3265,6 +3266,138 @@
     assertThat(notes.getReadOnlyUntil()).isEqualTo(new Timestamp(0));
   }
 
+  @Test
+  public void privateDefault() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void privateSetPrivate() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isTrue();
+  }
+
+  @Test
+  public void privateSetPrivateMultipleTimes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setPrivate(false);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void defaultReviewersByEmailIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putAndRemoveReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putRemoveAndAddBackReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putReviewerByEmailAndCcByEmail() throws Exception {
+    Address adrReviewer = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adrCc = new Address("Foo Bor", "foo.bar.2@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrReviewer, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrCc, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER))
+        .containsExactly(adrReviewer);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC))
+        .containsExactly(adrCc);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adrReviewer, adrCc);
+  }
+
+  @Test
+  public void putReviewerByEmailAndChangeToCc() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER)).isEmpty();
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC)).containsExactly(adr);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
   private boolean testJson() {
     return noteUtil.getWriteJson();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 25b5168..83dcf61 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestChanges;
@@ -382,6 +383,32 @@
     assertBodyEquals("Update patch set 1\n\nPatch-set: 1\nCurrent: true\n", update.getResult());
   }
 
+  @Test
+  public void reviewerByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(
+        new Address("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\n"
+            + "Reviewer-email: John Doe <j.doe@gerritcodereview.com>\n",
+        update.getResult());
+  }
+
+  @Test
+  public void ccByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(new Address("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\nCC-email: j.doe@gerritcodereview.com\n",
+        update.getResult());
+  }
+
   private RevCommit parseCommit(ObjectId id) throws Exception {
     if (id instanceof RevCommit) {
       return (RevCommit) id;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
index df3e405..a201e9c 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -159,14 +159,11 @@
     // Seed existing ref value.
     writeBlob("id", "1");
 
-    final AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
     Runnable bgUpdate =
-        new Runnable() {
-          @Override
-          public void run() {
-            if (!doneBgUpdate.getAndSet(true)) {
-              writeBlob("id", "1234");
-            }
+        () -> {
+          if (!doneBgUpdate.getAndSet(true)) {
+            writeBlob("id", "1234");
           }
         };
 
@@ -203,13 +200,10 @@
 
   @Test
   public void failAfterRetryerGivesUp() throws Exception {
-    final AtomicInteger bgCounter = new AtomicInteger(1234);
+    AtomicInteger bgCounter = new AtomicInteger(1234);
     Runnable bgUpdate =
-        new Runnable() {
-          @Override
-          public void run() {
-            writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000)));
-          }
+        () -> {
+          writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000)));
         };
     RepoSequence s =
         newSequence(
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 3719273..c2f28eb 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -71,6 +71,8 @@
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.QueryOptions;
@@ -83,6 +85,7 @@
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.RequestContext;
@@ -151,6 +154,8 @@
   @Inject protected SchemaCreator schemaCreator;
   @Inject protected Sequences seq;
   @Inject protected ThreadLocalRequestContext requestContext;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
 
   protected Injector injector;
   protected LifecycleManager lifecycle;
@@ -373,6 +378,30 @@
   }
 
   @Test
+  public void byPrivate() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    // No private changes.
+    assertQuery("is:open", change2, change1);
+    assertQuery("is:private");
+
+    gApi.changes().id(change1.getChangeId()).setPrivate(true);
+
+    // Change1 is not private, but should be still visible to its owner.
+    assertQuery("is:open", change1, change2);
+    assertQuery("is:private", change1);
+
+    // Switch request context to user2.
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("is:open", change2);
+    assertQuery("is:private");
+  }
+
+  @Test
   public void byCommit() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins = newChange(repo);
@@ -1521,6 +1550,71 @@
   }
 
   @Test
+  public void reviewerAndCcByEmail() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.setEnableReviewerByEmail(true);
+    saveProjectConfig(project, cfg);
+
+    String userByEmail = "un.registered@reviewer.com";
+    String userByEmailWithName = "John Doe <" + userByEmail + ">";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1);
+    assertQuery("cc:\"" + userByEmailWithName + "\"", change2);
+
+    // Omitting the name:
+    assertQuery("reviewer:\"" + userByEmail + "\"", change1);
+    assertQuery("cc:\"" + userByEmail + "\"", change2);
+  }
+
+  @Test
+  public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    cfg.setEnableReviewerByEmail(true);
+    saveProjectConfig(project, cfg);
+
+    String userByEmail = "John Doe <un.registered@reviewer.com>";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    assertQuery("reviewer:\"someone@example.com\"");
+    assertQuery("cc:\"someone@example.com\"");
+  }
+
+  @Test
   public void submitRecords() throws Exception {
     Account.Id user1 = createAccount("user1");
     TestRepository<Repo> repo = createProject("repo");
@@ -2003,4 +2097,12 @@
             Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
     gApi.changes().id(changeId).current().review(input);
   }
+
+  private void saveProjectConfig(Project.NameKey p, ProjectConfig cfg) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(p)) {
+      md.setAuthor(userFactory.create(userId));
+      cfg.commit(md);
+    }
+    projectCache.evict(cfg.getProject());
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
index 112fdd6..9a32365 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.ConfigNotesMigration;
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryH2Type;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
@@ -108,6 +109,7 @@
                     bind(DataSourceType.class).to(InMemoryH2Type.class);
 
                     bind(SystemGroupBackend.class);
+                    install(new ConfigNotesMigration.Module());
                   }
                 })
             .getInstance(SchemaUpdater.class);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
index bc465ec..e0e7659 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
@@ -224,31 +224,6 @@
    * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
    *
    * <pre>
-   * startThread(new Runnable() {
-   *   public void run() {
-   *     runImp();
-   *   }
-   * });
-   * </pre>
-   *
-   * @param thunk the runnable to execute on the thread, performing the command's logic.
-   */
-  protected void startThread(final Runnable thunk) {
-    startThread(
-        new CommandRunnable() {
-          @Override
-          public void run() throws Exception {
-            thunk.run();
-          }
-        });
-  }
-
-  /**
-   * Spawn a function into its own thread.
-   *
-   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
-   *
-   * <pre>
    * startThread(new CommandRunnable() {
    *   public void run() throws Exception {
    *     runImp();
@@ -469,6 +444,7 @@
   }
 
   /** Runnable function which can throw an exception. */
+  @FunctionalInterface
   public interface CommandRunnable {
     void run() throws Exception;
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index c6d750c..66b8fe6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -230,13 +230,7 @@
       Future<?> future = task.getAndSet(null);
       if (future != null) {
         future.cancel(true);
-        destroyExecutor.execute(
-            new Runnable() {
-              @Override
-              public void run() {
-                onDestroy();
-              }
-            });
+        destroyExecutor.execute(this::onDestroy);
       }
     }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
index c245667..589014c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
@@ -23,9 +23,9 @@
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
-import org.apache.sshd.common.keyprovider.AbstractFileKeyPairProvider;
+import org.apache.sshd.common.keyprovider.FileKeyPairProvider;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
-import org.apache.sshd.common.util.SecurityUtils;
+import org.apache.sshd.common.util.security.SecurityUtils;
 import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
 
 class HostKeyProvider implements Provider<KeyPairProvider> {
@@ -72,7 +72,7 @@
               + stdKeys
               + "");
     }
-    AbstractFileKeyPairProvider kp = SecurityUtils.createFileKeyPairProvider();
+    FileKeyPairProvider kp = new FileKeyPairProvider();
     kp.setFiles(stdKeys);
     return kp;
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
index d5b1712..f573c1d 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
@@ -86,10 +86,10 @@
 import org.apache.sshd.common.random.SingletonRandomFactory;
 import org.apache.sshd.common.session.ConnectionService;
 import org.apache.sshd.common.session.Session;
-import org.apache.sshd.common.util.SecurityUtils;
 import org.apache.sshd.common.util.buffer.Buffer;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.common.util.net.SshdSocketAddress;
+import org.apache.sshd.common.util.security.SecurityUtils;
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.CommandFactory;
 import org.apache.sshd.server.ServerBuilder;
@@ -420,7 +420,7 @@
   private void initKeyExchanges(Config cfg) {
     List<NamedFactory<KeyExchange>> a = ServerBuilder.setUpDefaultKeyExchanges(true);
     setKeyExchangeFactories(
-        filter(cfg, "kex", (NamedFactory<KeyExchange>[]) a.toArray(new NamedFactory[a.size()])));
+        filter(cfg, "kex", (NamedFactory<KeyExchange>[]) a.toArray(new NamedFactory<?>[a.size()])));
   }
 
   private void initProviderBouncyCastle(Config cfg) {
@@ -528,14 +528,14 @@
 
     a.add(null);
     setCipherFactories(
-        filter(cfg, "cipher", (NamedFactory<Cipher>[]) a.toArray(new NamedFactory[a.size()])));
+        filter(cfg, "cipher", (NamedFactory<Cipher>[]) a.toArray(new NamedFactory<?>[a.size()])));
   }
 
   @SuppressWarnings("unchecked")
   private void initMacs(Config cfg) {
     List<NamedFactory<Mac>> m = BaseBuilder.setUpDefaultMacs(true);
     setMacFactories(
-        filter(cfg, "mac", (NamedFactory<Mac>[]) m.toArray(new NamedFactory[m.size()])));
+        filter(cfg, "mac", (NamedFactory<Mac>[]) m.toArray(new NamedFactory<?>[m.size()])));
   }
 
   @SafeVarargs
@@ -683,12 +683,12 @@
     setTcpipForwardingFilter(
         new ForwardingFilter() {
           @Override
-          public boolean canForwardAgent(Session session) {
+          public boolean canForwardAgent(Session session, String requestType) {
             return false;
           }
 
           @Override
-          public boolean canForwardX11(Session session) {
+          public boolean canForwardX11(Session session, String requestType) {
             return false;
           }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 837865e..a3cf7c1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -14,14 +14,15 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.server.ssh.SshKeyCreator;
@@ -92,22 +93,23 @@
 
   static class Loader extends CacheLoader<String, Iterable<SshKeyCacheEntry>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final ExternalIds externalIds;
     private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema, VersionedAuthorizedKeys.Accessor authorizedKeys) {
+    Loader(
+        SchemaFactory<ReviewDb> schema,
+        ExternalIds externalIds,
+        VersionedAuthorizedKeys.Accessor authorizedKeys) {
       this.schema = schema;
+      this.externalIds = externalIds;
       this.authorizedKeys = authorizedKeys;
     }
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
       try (ReviewDb db = schema.open()) {
-        ExternalId user =
-            ExternalId.from(
-                db.accountExternalIds()
-                    .get(
-                        ExternalId.Key.create(SCHEME_USERNAME, username).asAccountExternalIdKey()));
+        ExternalId user = externalIds.get(db, ExternalId.Key.create(SCHEME_USERNAME, username));
         if (user == null) {
           return NO_SUCH_USER;
         }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 1ed0bb0..0c8c74a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -82,13 +82,7 @@
 
   @Override
   public void start(final Environment env) {
-    startThread(
-        new Runnable() {
-          @Override
-          public void run() {
-            runImp();
-          }
-        });
+    startThread(this::runImp);
   }
 
   private void runImp() {
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
index acc9b86..af269f1 100644
--- a/gerrit-war/pom.xml
+++ b/gerrit-war/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.14-SNAPSHOT</version>
+  <version>2.15-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml b/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
index 3ae9440..02aa1b9 100644
--- a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
+++ b/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
@@ -49,6 +49,10 @@
         <Set name="driverClassName">com.mysql.jdbc.Driver</Set>
         <Set name="url">jdbc:mysql://localhost/reviewdb?user=gerrit2&amp;password=secretkey</Set>
 -->
+<!--  MariaDB
+        <Set name="driverClassName">org.mariadb.jdbc.Driver</Set>
+        <Set name="url">jdbc:mariadb://localhost/reviewdb?user=gerrit2&amp;password=secretkey</Set>
+-->
 <!--  H2
         <Set name="driverClassName">org.h2.Driver</Set>
         <Set name="url">jdbc:h2:file:ReviewDb</Set>
diff --git a/lib/BUILD b/lib/BUILD
index fe1933c..c2148bf 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -264,6 +264,7 @@
         ":args4j",
         ":gson",
         ":guava",
+        ":html-types",
         ":icu4j",
         ":jsr305",
         ":protobuf",
@@ -279,6 +280,13 @@
 )
 
 java_library(
+    name = "html-types",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@html_types//jar"],
+)
+
+java_library(
     name = "icu4j",
     data = ["//lib:LICENSE-icu4j"],
     visibility = ["//visibility:public"],
diff --git a/lib/codemirror/cm.bzl b/lib/codemirror/cm.bzl
index fd56354..54d60d5 100644
--- a/lib/codemirror/cm.bzl
+++ b/lib/codemirror/cm.bzl
@@ -214,7 +214,7 @@
     "z80",
 ]
 
-CM_VERSION = "5.24.2"
+CM_VERSION = "5.25.0"
 
 TOP = "META-INF/resources/webjars/codemirror/%s" % CM_VERSION
 
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index ac843d8..679e9df 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,19 +1,66 @@
-load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL")
+load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
 
-JGIT_VERS = "4.6.0.201612231935-r.30-gd3148f300"
+_JGIT_VERS = "4.6.1.201703071140-r.149-g61f830d3a"
 
-DOC_VERS = "4.6.0.201612231935-r" # Set to JGIT_VERS unless using a snapshot
+_DOC_VERS = "4.6.0.201612231935-r" # Set to _JGIT_VERS unless using a snapshot
 
-JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + DOC_VERS + "/apidocs"
+JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
-JGIT_REPO = GERRIT # Leave here even if set to MAVEN_CENTRAL.
+_JGIT_REPO = GERRIT # Leave here even if set to MAVEN_CENTRAL.
 
-JGIT_SHA1 = "a2b5970b853f8fee64589fc1103c0ceb7677ba63"
+# set this to use a local version.
+# "/home/<user>/projects/jgit"
+LOCAL_JGIT_REPO = ""
 
-JGIT_SRC_SHA1 = "765f955774c36c226aa41fba7c20119451de2db7"
+def jgit_repos():
+  if LOCAL_JGIT_REPO:
+    native.local_repository(
+        name = "jgit",
+        path = LOCAL_JGIT_REPO,
+    )
+  else:
+    jgit_maven_repos()
 
-JGIT_SERVLET_SHA1 = "d3aa54bd610db9a5c246aa8fef13989982c98628"
+def jgit_maven_repos():
+    maven_jar(
+        name = "jgit_lib",
+        artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
+        repository = _JGIT_REPO,
+        sha1 = "dbb390b827b968558342e882e0c9b90e1ed037a2",
+        src_sha1 = "05d8939d08fe75a080fbf84f3163df5127950985",
+        unsign = True,
+    )
+    maven_jar(
+        name = "jgit_servlet",
+        artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
+        repository = _JGIT_REPO,
+        sha1 = "136026aa28b065d04194cadcb3371b5a3f6c7235",
+        unsign = True,
+    )
+    maven_jar(
+        name = "jgit_archive",
+        artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
+        repository = _JGIT_REPO,
+        sha1 = "8be5fa1000cf66ff3deae257bb29870c93f83363",
+    )
+    maven_jar(
+        name = "jgit_junit",
+        artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
+        repository = _JGIT_REPO,
+        sha1 = "d6a6c49b137a7f1a30ec55a228826d9146c0eba4",
+        unsign = True,
+    )
 
-JGIT_ARCHIVE_SHA1 = "a728cf277396f1227c5a8dffcf5dee0188fc0821"
+def jgit_dep(name):
+  mapping = {
+      "@jgit_junit//jar": "@jgit//org.eclipse.jgit.junit:junit",
+      "@jgit_lib//jar:src": "@jgit//org.eclipse.jgit:libjgit-src.jar",
+      "@jgit_lib//jar": "@jgit//org.eclipse.jgit:jgit",
+      "@jgit_servlet//jar":"@jgit//org.eclipse.jgit.http.server:jgit-servlet",
+      "@jgit_archive//jar": "@jgit//org.eclipse.jgit.archive:jgit-archive",
+  }
 
-JGIT_JUNIT_SHA1 = "6c2b2f192c95d25a2e1576aee5d1169dd8bd2266"
+  if LOCAL_JGIT_REPO:
+    return mapping[name]
+  else:
+    return name
diff --git a/lib/jgit/org.eclipse.jgit.archive/BUILD b/lib/jgit/org.eclipse.jgit.archive/BUILD
index d4e0a8c..198ff25 100644
--- a/lib/jgit/org.eclipse.jgit.archive/BUILD
+++ b/lib/jgit/org.eclipse.jgit.archive/BUILD
@@ -1,7 +1,9 @@
+load("//lib/jgit:jgit.bzl", "jgit_dep")
+
 java_library(
     name = "jgit-archive",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
-    exports = ["@jgit_archive//jar"],
+    exports = [jgit_dep("@jgit_archive//jar")],
     runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
 )
diff --git a/lib/jgit/org.eclipse.jgit.http.server/BUILD b/lib/jgit/org.eclipse.jgit.http.server/BUILD
index c448c4b..6b5bf78 100644
--- a/lib/jgit/org.eclipse.jgit.http.server/BUILD
+++ b/lib/jgit/org.eclipse.jgit.http.server/BUILD
@@ -1,7 +1,9 @@
+load("//lib/jgit:jgit.bzl", "jgit_dep")
+
 java_library(
     name = "jgit-servlet",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
-    exports = ["@jgit_servlet//jar"],
+    exports = [jgit_dep("@jgit_servlet//jar")],
     runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
 )
diff --git a/lib/jgit/org.eclipse.jgit.junit/BUILD b/lib/jgit/org.eclipse.jgit.junit/BUILD
index 2c8966a..ba6c42f 100644
--- a/lib/jgit/org.eclipse.jgit.junit/BUILD
+++ b/lib/jgit/org.eclipse.jgit.junit/BUILD
@@ -1,7 +1,10 @@
+load("//lib/jgit:jgit.bzl", "jgit_dep")
+
 java_library(
     name = "junit",
+    testonly = 1,
     data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
     visibility = ["//visibility:public"],
-    exports = ["@jgit_junit//jar"],
+    exports = [jgit_dep("@jgit_junit//jar")],
     runtime_deps = ["//lib/jgit/org.eclipse.jgit:jgit"],
 )
diff --git a/lib/jgit/org.eclipse.jgit/BUILD b/lib/jgit/org.eclipse.jgit/BUILD
index 33de929..5586cb1 100644
--- a/lib/jgit/org.eclipse.jgit/BUILD
+++ b/lib/jgit/org.eclipse.jgit/BUILD
@@ -1,11 +1,19 @@
+load("//lib/jgit:jgit.bzl", "jgit_dep")
+
 java_library(
     name = "jgit",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
-    exports = ["@jgit//jar"],
+    exports = [jgit_dep("@jgit_lib//jar")],
     runtime_deps = [":javaewah"],
 )
 
+alias(
+    name = "jgit-source",
+    actual = jgit_dep("@jgit_lib//jar:src"),
+    visibility = ["//visibility:public"],
+)
+
 java_library(
     name = "javaewah",
     data = ["//lib:LICENSE-Apache2.0"],
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
deleted file mode 160000
index e6d7594..0000000
--- a/plugins/cookbook-plugin
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit e6d7594621d87859a0e6af361cac1fc3173c3588
diff --git a/plugins/replication b/plugins/replication
index 305c864..47c2691 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 305c864db28eb0c77c8499bc04c87de3f849cf3c
+Subproject commit 47c2691a109ffedf8737f3697a80eef82dd5a4a9
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 00e39d9..0dd9e39 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
@@ -42,6 +42,12 @@
      * @event page-error
      */
 
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
     properties: {
       /**
        * URL params passed from the router.
@@ -718,11 +724,18 @@
 
     _handleAKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) ||
-          !this._loggedIn) { return; }
+          this.modifierPressed(e)) {
+        return;
+      }
+      this._getLoggedIn().then(function(isLoggedIn) {
+        if (!isLoggedIn) {
+          this.fire('show-auth-required');
+          return;
+        }
 
-      e.preventDefault();
-      this._openReplyDialog();
+        e.preventDefault();
+        this._openReplyDialog();
+      }.bind(this));
     },
 
     _handleDKey: function(e) {
@@ -818,12 +831,10 @@
 
     _handleReloadChange: function(e) {
       return this._reload().then(function() {
-        // If the change was rebased, we need to reload the related changes.
+        // If the change was rebased, we need to reload the page with the
+        // latest patch.
         if (e.detail.action === 'rebase') {
-          this.$.relatedChanges.reload();
-          this.set('_patchRange.patchNum',
-              this._computeLatestPatchNum(this._allPatchSets));
-          this._updateSelected();
+          page.show(this.changePath(this._changeNum));
         }
       }.bind(this));
     },
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 884142c..fc20ef8 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
@@ -35,10 +35,12 @@
   suite('gr-change-view tests', function() {
     var element;
     var sandbox;
+    var showStub;
     var TEST_SCROLL_TOP_PX = 100;
 
     setup(function() {
       sandbox = sinon.sandbox.create();
+      showStub = sandbox.stub(page, 'show');
       stub('gr-rest-api-interface', {
         getConfig: function() { return Promise.resolve({}); },
         getAccount: function() { return Promise.resolve(null); },
@@ -58,31 +60,46 @@
       });
 
       test('U should navigate to / if no backPage set', function() {
-        var showStub = sandbox.stub(page, 'show');
         MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
         assert(showStub.lastCall.calledWithExactly('/'));
       });
 
       test('U should navigate to backPage if set', function() {
         element.backPage = '/dashboard/self';
-        var showStub = sandbox.stub(page, 'show');
         MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
         assert(showStub.lastCall.calledWithExactly('/dashboard/self'));
       });
 
-      test('A should toggle overlay', function() {
+      test('A fires an error event when not logged in', function(done) {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+        var loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
         MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        var overlayEl = element.$.replyOverlay;
-        assert.isFalse(overlayEl.opened);
-        element._loggedIn = true;
+        flush(function() {
+          assert.isFalse(element.$.replyOverlay.opened);
+          assert.isTrue(loggedInErrorSpy.called);
+          done();
+        });
+      });
 
+      test('shift A does not open reply overlay', function(done) {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
         MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-        assert.isFalse(overlayEl.opened);
+        flush(function() {
+          assert.isFalse(element.$.replyOverlay.opened);
+          done();
+        });
+      });
 
+      test('A toggles overlay when logged in', function(done) {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
         MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        assert.isTrue(overlayEl.opened);
-        overlayEl.close();
-        assert.isFalse(overlayEl.opened);
+        flush(function() {
+          assert.isTrue(element.$.replyOverlay.opened);
+          element.$.replyOverlay.close();
+          assert.isFalse(element.$.replyOverlay.opened);
+          done();
+        });
       });
 
       test('X should expand all messages', function() {
@@ -120,7 +137,8 @@
 
         sandbox.stub(element.$.actions, 'reload');
 
-        var showStub = sandbox.stub(page, 'show', function(arg) {
+        showStub.restore();
+        showStub = sandbox.stub(page, 'show', function(arg) {
           assert.equal(arg, '/c/42');
           done();
         });
@@ -401,8 +419,6 @@
       assert.notEqual(select, 3);
       assert.equal(optionEls[3].value, 13);
 
-      var showStub = sandbox.stub(page, 'show');
-
       var numEvents = 0;
       selectEl.addEventListener('change', function(e) {
         assert.equal(element.viewState.diffMode, 'UNIFIED');
@@ -453,8 +469,6 @@
         element.$$('.patchInfo-header #patchSetSelect').bindValue, 3);
       assert.equal(optionEls[3].value, 13);
 
-      var showStub = sandbox.stub(page, 'show');
-
       var numEvents = 0;
       selectEl.addEventListener('change', function(e) {
         numEvents++;
@@ -531,8 +545,6 @@
         labels: {},
       };
 
-      var showStub = sandbox.stub(page, 'show');
-
       element._changePatchNum(13);
       assert(showStub.lastCall.calledWithExactly('/c/42/2..13'));
 
@@ -544,17 +556,15 @@
 
     test('related changes are updated and new patch selected after rebase',
         function(done) {
+      element._changeNum = '42';
       sandbox.stub(element, '_computeLatestPatchNum', function() {
         return 1;
       });
       sandbox.stub(element, '_reload',
           function() { return Promise.resolve(); });
-      sandbox.stub(element, '_updateSelected');
-      sandbox.stub(element.$.relatedChanges, 'reload');
       var e = {detail: {action: 'rebase'}};
       element._handleReloadChange(e).then(function() {
-        assert.isTrue(element.$.relatedChanges.reload.called);
-        assert.isTrue(element._updateSelected.called);
+        assert.isTrue(showStub.lastCall.calledWithExactly('/c/42'));
         done();
       });
     });
@@ -566,8 +576,7 @@
       sandbox.stub(element.$.relatedChanges, 'reload');
       var e = {detail: {action: 'abandon'}};
       element._handleReloadChange(e).then(function() {
-        assert.isFalse(element.$.relatedChanges.reload.called);
-        assert.isFalse(element._updateSelected.called);
+        assert.isFalse(showStub.called);
         done();
       });
     });
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index 577b983..ecd4dcc 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -34,11 +34,15 @@
       return '/c/' + changeNum + '/' + patchNum + '/' + file;
     },
 
+    _isOnParent: function(comment) {
+      return comment.side === 'PARENT';
+    },
+
     _computeDiffLineURL: function(file, changeNum, patchNum, comment) {
       var diffURL = this._computeFileDiffURL(file, changeNum, patchNum);
       if (comment.line) {
         diffURL += '#';
-        if (comment.__isOnParent) { diffURL += 'b'; }
+        if (this._isOnParent(comment)) { diffURL += 'b'; }
         diffURL += comment.line;
       }
       return diffURL;
@@ -51,7 +55,7 @@
     },
 
     _computePatchDisplayName: function(comment) {
-      if (comment.__isOnParent) {
+      if (this._isOnParent(comment)) {
         return 'Base, ';
       }
       if (comment.patch_set != this.patchNum) {
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index 0f4344c..d65cc9f 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -64,14 +64,14 @@
     });
 
     test('_computeDiffLineURL', function() {
-      var comment = {line: 123, side: 'REIVISION', patch_set: 10};
+      var comment = {line: 123, side: 'REVISION', patch_set: 10};
       var expected = '/c/<change>/<patch>/<file>#123';
       var actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
           comment);
       assert.equal(actual, expected);
 
       comment.line = 321;
-      comment.__isOnParent = true;
+      comment.side = 'PARENT';
 
       expected = '/c/<change>/<patch>/<file>#b321';
       actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
@@ -79,7 +79,7 @@
     });
 
     test('_computePatchDisplayName', function() {
-      var comment = {line: 123, side: 'REIVISION', patch_set: 10};
+      var comment = {line: 123, side: 'REVISION', patch_set: 10};
 
       element.patchNum = 10;
       assert.equal(element._computePatchDisplayName(comment), '');
@@ -87,7 +87,7 @@
       element.patchNum = 9;
       assert.equal(element._computePatchDisplayName(comment), 'PS10, ');
 
-      comment.__isOnParent = true;
+      comment.side = 'PARENT';
       assert.equal(element._computePatchDisplayName(comment), 'Base, ');
     });
   });
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 a88e0eb..0698a0c 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
@@ -238,7 +238,7 @@
         initial-count="[[_fileListIncrement]]">
       <div class="file-row row">
         <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
-          <input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]"
+          <input type="checkbox" checked="[[file.isReviewed]]"
               data-path$="[[file.__path]]" on-change="_handleReviewedChange"
               class="reviewed" aria-label="Reviewed checkbox">
         </div>
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 4be2ca7..a0189f2 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
@@ -118,6 +118,7 @@
 
     observers: [
       '_expandedPathsChanged(_expandedFilePaths.splices)',
+      '_setReviewedFiles(_shownFiles, _files, _reviewed.*, _loggedIn)',
     ],
 
     keyBindings: {
@@ -360,7 +361,10 @@
     },
 
     _handleReviewedChange: function(e) {
-      var path = Polymer.dom(e).rootTarget.getAttribute('data-path');
+      this._reviewFile(Polymer.dom(e).rootTarget.getAttribute('data-path'));
+    },
+
+    _reviewFile: function(path) {
       var index = this._reviewed.indexOf(path);
       var reviewed = index !== -1;
       if (reviewed) {
@@ -369,11 +373,7 @@
         this.push('_reviewed', path);
       }
 
-      this._saveReviewedState(path, !reviewed).catch(function(err) {
-        alert('Couldn’t change file review status. Check the console ' +
-            'and contact the PolyGerrit team for assistance.');
-        throw err;
-      }.bind(this));
+      this._saveReviewedState(path, !reviewed);
     },
 
     _saveReviewedState: function(path, reviewed) {
@@ -658,6 +658,19 @@
       return files.base.slice(0, numFilesShown);
     },
 
+    _setReviewedFiles: function(shownFiles, files, reviewedRecord, loggedIn) {
+      if (!loggedIn) { return; }
+      var reviewed = reviewedRecord.base;
+      var fileReviewed;
+      for (var i = 0; i < files.length; i++) {
+        fileReviewed = this._computeReviewed(files[i], reviewed);
+        this._files[i].isReviewed = fileReviewed;
+        if (i < shownFiles.length) {
+          this.set(['_shownFiles', i, 'isReviewed'], fileReviewed);
+        }
+      }
+    },
+
     _filesChanged: function() {
       this.async(function() {
         var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
@@ -787,7 +800,11 @@
       console.log('Expanding diff', 1 + initialCount - paths.length, 'of',
           initialCount, ':', paths[0]);
       var diffElem = this._findDiffByPath(paths[0], diffElements);
-      return diffElem.reload().then(function() {
+      var promises = [diffElem.reload()];
+      if (this._isLoggedIn) {
+        promises.push(this._reviewFile(paths[0]));
+      }
+      return Promise.all(promises).then(function() {
         return this._renderInOrder(paths.slice(1), diffElements, initialCount);
       }.bind(this));
     },
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 659a9c3..66ad66c 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
@@ -42,6 +42,7 @@
   suite('gr-file-list tests', function() {
     var element;
     var sandbox;
+    var saveStub;
 
     setup(function() {
       sandbox = sinon.sandbox.create();
@@ -51,12 +52,14 @@
         fetchJSON: function() { return Promise.resolve({}); },
       });
       stub('gr-date-formatter', {
-        _loadTimeFormat: function() { return Promise.resolve(''); }
+        _loadTimeFormat: function() { return Promise.resolve(''); },
       });
       stub('gr-diff', {
         reload: function() { return Promise.resolve(); },
       });
       element = fixture('basic');
+      saveStub = sandbox.stub(element, '_saveReviewedState',
+          function() { return Promise.resolve(); });
     });
 
     teardown(function() {
@@ -532,6 +535,7 @@
         {__path: 'myfile.txt'},
       ];
       element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._loggedIn = true;
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
@@ -553,15 +557,10 @@
       assert.isFalse(fileAdded.checked);
       assert.isTrue(myFile.checked);
 
-      var saveStub = sandbox.stub(element, '_saveReviewedState',
-          function() { return Promise.resolve(); });
-
       MockInteractions.tap(commitMsg);
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
       MockInteractions.tap(commitMsg);
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
-
-      saveStub.restore();
     });
 
     test('patch set from revisions', function() {
@@ -577,7 +576,7 @@
           rev1: {_number: 1, description: 'test'},
           rev4: {_number: 4, description: 'test'},
           rev2: {_number: 2, description: 'test'},
-        }
+        },
       });
       assert.equal(patchNums.length, expected.length);
       for (var i = 0; i < expected.length; i++) {
@@ -780,24 +779,29 @@
     test('_togglePathExpanded', function() {
       var path = 'path/to/my/file.txt';
       element.files = [{__path: path}];
-      var expandedChangedStub = sandbox.stub(element, '_expandedPathsChanged');
+      var renderStub = sandbox.stub(element, '_renderInOrder')
+          .returns(Promise.resolve());
 
       assert.equal(element._expandedFilePaths.length, 0);
       element._togglePathExpanded(path);
-      assert.equal(expandedChangedStub.callCount, 1);
+      flushAsynchronousOperations();
+
+      assert.equal(renderStub.callCount, 1);
       assert.include(element._expandedFilePaths, path);
       element._togglePathExpanded(path);
-      assert.equal(expandedChangedStub.callCount, 2);
+      flushAsynchronousOperations();
+
+      assert.equal(renderStub.callCount, 2);
       assert.notInclude(element._expandedFilePaths, path);
     });
 
     test('_expandedPathsChanged', function(done) {
+      sandbox.stub(element, '_reviewFile');
       var path = 'path/to/my/file.txt';
       var diffs = [{
         path: path,
         reload: function() {
           done();
-          return Promise.resolve();
         },
       }];
       var diffsStub = sinon.stub(element, 'diffs', {
@@ -837,6 +841,7 @@
     });
 
     test('_renderInOrder', function(done) {
+      var reviewStub = sandbox.stub(element, '_reviewFile');
       var callCount = 0;
       var diffs = [{
         path: 'p0',
@@ -858,7 +863,43 @@
         },
       }];
       element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
-        .then(function() { done(); });
+        .then(function() {
+          assert.isFalse(reviewStub.called);
+          done();
+        });
+    });
+
+    test('_renderInOrder logged in', function(done) {
+      element._isLoggedIn = true;
+      var reviewStub = sandbox.stub(element, '_reviewFile');
+      var callCount = 0;
+      var diffs = [{
+        path: 'p0',
+        reload: function() {
+          assert.equal(reviewStub.callCount, 2);
+          assert.equal(callCount++, 2);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p1',
+        reload: function() {
+          assert.equal(reviewStub.callCount, 1);
+          assert.equal(callCount++, 1);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p2',
+        reload: function() {
+          assert.equal(reviewStub.callCount, 0);
+          assert.equal(callCount++, 0);
+          return Promise.resolve();
+        },
+      }];
+      element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
+        .then(function() {
+          assert.equal(reviewStub.callCount, 3);
+          done();
+        });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index ca3c40e..e9ae55d 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -30,10 +30,11 @@
         border-top: 1px solid #ddd;
         display: block;
         position: relative;
-      }
-      :host(:not([expanded])) {
         cursor: pointer;
       }
+      :host(.expanded) {
+        cursor: auto;
+      }
       gr-avatar {
         position: absolute;
         left: var(--default-horizontal-margin);
@@ -81,7 +82,7 @@
         overflow: hidden;
         text-overflow: ellipsis;
       }
-      .collapsed .name,
+      .collapsed .author,
       .collapsed .content,
       .collapsed .message,
       .collapsed .updateCategory,
@@ -107,11 +108,11 @@
       .collapsed .date {
         position: static;
       }
-      .collapsed .name {
+      .collapsed .author {
         color: var(--default-text-color);
         margin-right: .4em;
       }
-      .expanded .name {
+      .expanded .author {
         cursor: pointer;
       }
       .date {
@@ -127,7 +128,13 @@
     <div class$="[[_computeClass(_expanded, showAvatar)]]">
       <gr-avatar account="[[author]]" image-size="100"></gr-avatar>
       <div class="contentContainer">
-        <div class="name" on-tap="_handleNameTap">[[author.name]]</div>
+        <div class="author" on-tap="_handleAuthorTap">
+          <span hidden$="[[!showOnBehalfOf]]">
+            <span class="name">[[message.real_author.name]]</span>
+            on behalf of
+          </span>
+          <span class="name">[[author.name]]</span>
+        </div>
         <template is="dom-if" if="[[message.message]]">
           <div class="content">
             <div class="message hideOnOpen">[[message.message]]</div>
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 cebaf5c..5467af9 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -62,6 +62,10 @@
         type: Boolean,
         computed: '_computeShowAvatar(author, config)',
       },
+      showOnBehalfOf: {
+        type: Boolean,
+        computed: '_computeShowOnBehalfOf(message)',
+      },
       showReplyButton: {
         type: Boolean,
         computed: '_computeShowReplyButton(message, _loggedIn)',
@@ -78,6 +82,10 @@
       },
     },
 
+    observers: [
+      '_updateExpandedClass(message.expanded)',
+    ],
+
     ready: function() {
       this.$.restAPI.getConfig().then(function(config) {
         this.config = config;
@@ -87,6 +95,14 @@
       }.bind(this));
     },
 
+    _updateExpandedClass: function(expanded) {
+      if (expanded) {
+        this.classList.add('expanded');
+      } else {
+        this.classList.remove('expanded');
+      }
+    },
+
     _computeAuthor: function(message) {
       return message.author || message.updated_by;
     },
@@ -95,6 +111,12 @@
       return !!(author && config && config.plugin && config.plugin.has_avatars);
     },
 
+    _computeShowOnBehalfOf: function(message) {
+      var author = message.author || message.updated_by;
+      return !!(author && message.real_author &&
+          author._account_id != message.real_author._account_id);
+    },
+
     _computeShowReplyButton: function(message, loggedIn) {
       return !!message.message && loggedIn;
     },
@@ -120,7 +142,7 @@
       this.set('message.expanded', true);
     },
 
-    _handleNameTap: function(e) {
+    _handleAuthorTap: function(e) {
       if (!this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', false);
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 0f6c85d..1d759ae 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
@@ -90,7 +90,7 @@
       var content = element.$$('.contentContainer');
       assert.isOk(content);
       assert.strictEqual(element.$$('gr-account-chip').account, reviewer);
-      assert.equal(author.name, element.$$('.name').textContent);
+      assert.equal(author.name, element.$$('.author > .name').textContent);
     });
 
     test('autogenerated prefix hiding', function() {
@@ -143,5 +143,23 @@
       assert.isFalse(element._computeShowReplyButton(message, false));
       assert.isTrue(element._computeShowReplyButton(message, true));
     });
+
+    test('_computeShowOnBehalfOf', function() {
+      var message = {
+        message: '...',
+      };
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author._account_id = 123456;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      message.updated_by = message.author;
+      delete message.author;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      delete message.updated_by;
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index f56d85d..80ccd11 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -188,15 +188,21 @@
      * accounts if necessary.
      *
      * @param {Boolean} isCancel true if the action is a cancel.
+     * @param {Object} opt_accountIdsTransferred map of account IDs that must
+     *     not be removed, because they have been readded in another state.
      */
-    _purgeReviewersPendingRemove: function(isCancel) {
+    _purgeReviewersPendingRemove: function(isCancel,
+        opt_accountIdsTransferred) {
       var reviewerArr;
+      var keep = opt_accountIdsTransferred || {};
       for (var type in this._reviewersPendingRemove) {
         if (this._reviewersPendingRemove.hasOwnProperty(type)) {
           if (!isCancel) {
             reviewerArr = this._reviewersPendingRemove[type];
             for (var i = 0; i < reviewerArr.length; i++) {
-              this._removeAccount(reviewerArr[i], type);
+              if (!keep[reviewerArr[i]._account_id]) {
+                this._removeAccount(reviewerArr[i], type);
+              }
             }
           }
           this._reviewersPendingRemove[type] = [];
@@ -271,9 +277,18 @@
         obj.message = this.draft;
       }
 
-      obj.reviewers = this.$.reviewers.additions().map(this._mapReviewer);
+      var accountAdditions = {};
+      obj.reviewers = this.$.reviewers.additions().map(function(reviewer) {
+        if (reviewer.account) {
+          accountAdditions[reviewer.account._account_id] = true;
+        }
+        return this._mapReviewer(reviewer);
+      }.bind(this));
       if (this.serverConfig.note_db_enabled) {
         this.$$('#ccs').additions().forEach(function(reviewer) {
+          if (reviewer.account) {
+            accountAdditions[reviewer.account._account_id] = true;
+          }
           reviewer = this._mapReviewer(reviewer);
           reviewer.state = 'CC';
           obj.reviewers.push(reviewer);
@@ -290,6 +305,7 @@
         this.disabled = false;
         this.draft = '';
         this.fire('send', null, {bubbles: false});
+        return accountAdditions;
       }.bind(this)).catch(function(err) {
         this.disabled = false;
         throw err;
@@ -509,8 +525,8 @@
 
     _sendTapHandler: function(e) {
       e.preventDefault();
-      this.send().then(function() {
-        this._purgeReviewersPendingRemove();
+      this.send().then(function(keep) {
+        this._purgeReviewersPendingRemove(false, keep);
       }.bind(this));
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index f7058bd..7adc4a3 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -558,5 +558,68 @@
         done();
       });
     });
+
+    test('migrate reviewers between states', function(done) {
+      element.serverConfig = {note_db_enabled: true};
+      element._reviewersPendingRemove = {
+        CC: [],
+        REVIEWER: [],
+      };
+      flushAsynchronousOperations();
+      var reviewers = element.$.reviewers;
+      var ccs = element.$$('#ccs');
+      var reviewer1 = makeAccount();
+      var reviewer2 = makeAccount();
+      var cc1 = makeAccount();
+      var cc2 = makeAccount();
+      element._reviewers = [reviewer1, reviewer2];
+      element._ccs = [cc1, cc2];
+
+      var mutations = [];
+
+      var saveReviewStub = sandbox.stub(element, '_saveReview',
+          function(review) {
+        mutations.push.apply(mutations, review.reviewers);
+        return Promise.resolve({ok: true});
+      });
+
+      var removeAccountStub = sandbox.stub(element, '_removeAccount',
+          function(account, type) {
+        mutations.push({state: 'REMOVED', account: account});
+        return Promise.resolve();
+      });
+
+      // Remove and add to other field.
+      reviewers.fire('remove', {account: reviewer1});
+      ccs.$.entry.fire('add', {value: {account: reviewer1}});
+      ccs.fire('remove', {account: cc1});
+      reviewers.$.entry.fire('add', {value: {account: cc1}});
+
+      // Add to other field without removing from former field.
+      // (Currently not possible in UI, but this is a good consistency check).
+      reviewers.$.entry.fire('add', {value: {account: cc2}});
+      ccs.$.entry.fire('add', {value: {account: reviewer2}});
+      var mapReviewer = function(reviewer, opt_state) {
+        var result = {reviewer: reviewer._account_id, confirmed: undefined};
+        if (opt_state) {
+          result.state = opt_state;
+        }
+        return result;
+      };
+
+      // Send and purge and verify moves without deletions.
+      element.send()
+          .then(element._purgeReviewersPendingRemove.bind(element))
+          .then(function() {
+        assert.deepEqual(
+            mutations, [
+                mapReviewer(cc1),
+                mapReviewer(cc2),
+                mapReviewer(reviewer1, 'CC'),
+                mapReviewer(reviewer2, 'CC'),
+            ]);
+        done();
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 4011135..8a8ceaf 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -14,37 +14,75 @@
 (function() {
   'use strict';
 
+  var INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
+
   Polymer({
     is: 'gr-account-dropdown',
 
     properties: {
       account: Object,
-      _hasAvatars: Boolean,
       links: {
         type: Array,
-        value: [
-          {name: 'Settings', url: '/settings'},
-          {name: 'Switch account', url: '/switch-account'},
-          {name: 'Sign out', url: '/logout'},
-        ],
+        computed: '_getLinks(_switchAccountUrl)',
       },
       topContent: {
         type: Array,
         computed: '_getTopContent(account)',
       },
+      _path: {
+        type: String,
+        value: '/',
+      },
+      _hasAvatars: Boolean,
+      _switchAccountUrl: String,
     },
 
     attached: function() {
+      this._handleLocationChange();
+      this.listen(window, 'location-change', '_handleLocationChange');
       this.$.restAPI.getConfig().then(function(cfg) {
+        if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+          this._switchAccountUrl = cfg.auth.switch_account_url;
+        } else {
+          this._switchAccountUrl = null;
+        }
         this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
       }.bind(this));
     },
 
+    detached: function() {
+      this.unlisten(window, 'location-change', '_handleLocationChange');
+    },
+
+    _getLinks: function(switchAccountUrl) {
+      var links = [{name: 'Settings', url: '/settings'}];
+      if (switchAccountUrl) {
+        var replacements = {path: this._path};
+        var url = this._interpolateUrl(switchAccountUrl, replacements);
+        links.push({name: 'Switch account', url: url});
+      }
+      links.push({name: 'Sign out', url: '/logout'});
+      return links;
+    },
+
     _getTopContent: function(account) {
       return [
         {text: account.name, bold: true},
         {text: account.email},
       ];
     },
+
+    _handleLocationChange: function() {
+      this._path =
+          window.location.pathname +
+          window.location.search +
+          window.location.hash;
+    },
+
+    _interpolateUrl: function(url, replacements) {
+      return url.replace(INTERPOLATE_URL_PATTERN, function(match, p1) {
+        return replacements[p1] || '';
+      });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index ec9141f..8ea4701 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -46,5 +46,39 @@
       assert.deepEqual(element.topContent,
           [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
     });
+
+    test('switch account', function() {
+      // No switch account link.
+      assert.equal(element._getLinks(null).length, 2);
+
+      // Unparameterized switch account link.
+      var links = element._getLinks('/switch-account');
+      assert.equal(links.length, 3);
+      assert.deepEqual(links[1],
+          {name: 'Switch account', url: '/switch-account'});
+
+      // Parameterized switch account link.
+      element._path = '/c/123';
+      links = element._getLinks('/switch-account${path}');
+      assert.equal(links.length, 3);
+      assert.deepEqual(links[1],
+          {name: 'Switch account', url: '/switch-account/c/123'});
+    });
+
+    test('_interpolateUrl', function() {
+      var replacements = {
+        'foo': 'bar',
+        'test': 'TEST',
+      };
+      var interpolate = function(url) {
+        return element._interpolateUrl(url, replacements);
+      };
+
+      assert.equal(interpolate('test'), 'test');
+      assert.equal(interpolate('${test}'), 'TEST');
+      assert.equal(
+          interpolate('${}, ${test}, ${TEST}, ${foo}'),
+          '${}, TEST, , bar');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index 80f293d..2d7d2a9 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -24,4 +24,3 @@
   </template>
   <script src="gr-error-manager.js"></script>
 </dom-module>
-
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index bee95ea..870e7ea 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -15,7 +15,8 @@
   'use strict';
 
   var HIDE_ALERT_TIMEOUT_MS = 5000;
-  var CHECK_SIGN_IN_INTERVAL_MS = 60000;
+  var CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
+  var STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
   var SIGN_IN_WIDTH_PX = 690;
   var SIGN_IN_HEIGHT_PX = 500;
   var TOO_MANY_FILES = 'too many files to find conflicts';
@@ -24,33 +25,60 @@
     is: 'gr-error-manager',
 
     properties: {
+      /**
+       * The ID of the account that was logged in when the app was launched. If
+       * not set, then there was no account at launch.
+       */
+      knownAccountId: Number,
+
       _alertElement: Element,
       _hideAlertHandle: Number,
+      _refreshingCredentials: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * The time (in milliseconds) since the most recent credential check.
+       */
+      _lastCredentialCheck: {
+        type: Number,
+        value: function() { return Date.now(); },
+      }
     },
 
     attached: function() {
       this.listen(document, 'server-error', '_handleServerError');
       this.listen(document, 'network-error', '_handleNetworkError');
       this.listen(document, 'show-alert', '_handleShowAlert');
+      this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+      this.listen(document, 'show-auth-required', '_handleAuthRequired');
     },
 
     detached: function() {
       this._clearHideAlertHandle();
       this.unlisten(document, 'server-error', '_handleServerError');
       this.unlisten(document, 'network-error', '_handleNetworkError');
+      this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
+      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
     },
 
     _shouldSuppressError: function(msg) {
       return msg.indexOf(TOO_MANY_FILES) > -1;
     },
 
+    _handleAuthRequired: function() {
+      this._showAuthErrorAlert(
+          'Log in is required to perform that action.', 'Log in.');
+    },
+
     _handleServerError: function(e) {
       if (e.detail.response.status === 403) {
         this._getLoggedIn().then(function(loggedIn) {
           if (loggedIn) {
             // The app was logged at one point and is now getting auth errors.
             // This indicates the auth token is no longer valid.
-            this._showAuthErrorAlert();
+            this._showAuthErrorAlert('Auth error', 'Refresh credentials.');
           }
         }.bind(this));
       } else {
@@ -100,17 +128,15 @@
       }
     },
 
-    _showAuthErrorAlert: function() {
+    _showAuthErrorAlert: function(errorText, actionText) {
       // TODO(viktard): close alert if it's not for auth error.
       if (this._alertElement) { return; }
 
       this._alertElement = this._createToastAlert();
-      this._alertElement.show('Auth error', 'Refresh credentials.');
+      this._alertElement.show(errorText, actionText);
       this.listen(this._alertElement, 'action', '_createLoginPopup');
 
-      if (typeof document.hidden !== undefined) {
-        this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-      }
+      this._refreshingCredentials = true;
       this._requestCheckLoggedIn();
       if (!document.hidden) {
         this._handleVisibilityChange();
@@ -124,8 +150,19 @@
     },
 
     _handleVisibilityChange: function() {
-      if (!document.hidden) {
-        this.flushDebouncer('checkLoggedIn');
+      // Ignore when the page is transitioning to hidden (or hidden is
+      // undefined).
+      if (document.hidden !== false) { return; }
+
+      // If not currently refreshing credentials and the credentials are old,
+      // request them to confirm their validity or (display an auth toast if it
+      // fails).
+      var timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
+      if (!this._refreshingCredentials &&
+          this.knownAccountId !== undefined &&
+          timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
+        this._lastCredentialCheck = Date.now();
+        this.$.restAPI.checkCredentials();
       }
     },
 
@@ -135,16 +172,32 @@
     },
 
     _checkSignedIn: function() {
-      this.$.restAPI.refreshCredentials().then(function(isLoggedIn) {
-        if (isLoggedIn) {
-          this._handleCredentialRefresh();
-        } else {
-          this._requestCheckLoggedIn();
+      this.$.restAPI.checkCredentials().then(function(account) {
+        var isLoggedIn = !!account;
+        this._lastCredentialCheck = Date.now();
+        if (this._refreshingCredentials) {
+          if (isLoggedIn) {
+
+            // If the credentials were refreshed but the account is different
+            // then reload the page completely.
+            if (account._account_id !== this.knownAccountId) {
+              this._reloadPage();
+              return;
+            }
+
+            this._handleCredentialRefreshed();
+          } else {
+            this._requestCheckLoggedIn();
+          }
         }
       }.bind(this));
     },
 
-    _createLoginPopup: function(e) {
+    _reloadPage: function() {
+      window.location.reload();
+    },
+
+    _createLoginPopup: function() {
       var left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
       var top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
       var options = [
@@ -152,16 +205,21 @@
         'height=' + SIGN_IN_HEIGHT_PX,
         'left=' + left,
         'top=' + top,
-        'noopener=yes',
       ];
       window.open('/login/%3FcloseAfterLogin', '_blank', options.join(','));
+      this.listen(window, 'focus', '_handleWindowFocus');
     },
 
-    _handleCredentialRefresh: function() {
-      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    _handleCredentialRefreshed: function() {
+      this.unlisten(window, 'focus', '_handleWindowFocus');
+      this._refreshingCredentials = false;
       this.unlisten(this._alertElement, 'action', '_createLoginPopup');
       this._hideAlert();
       this._showAlert('Credentials refreshed.');
     },
+
+    _handleWindowFocus: function() {
+      this.flushDebouncer('checkLoggedIn');
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 91780d2..9be32af 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -56,6 +56,13 @@
       });
     });
 
+    test('show logged in error', function() {
+      sandbox.stub(element, '_showAuthErrorAlert');
+      element.fire('show-auth-required');
+      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
+          'Log in is required to perform that action.', 'Log in.'));
+    });
+
     test('show normal server error', function(done) {
       var showAlertStub = sandbox.stub(element, '_showAlert');
       var textSpy = sandbox.spy(function() { return Promise.resolve('ZOMG'); });
@@ -99,7 +106,7 @@
     });
 
     test('show auth refresh toast', function(done) {
-      var refreshStub = sandbox.stub(element.$.restAPI, 'refreshCredentials',
+      var refreshStub = sandbox.stub(element.$.restAPI, 'checkCredentials',
           function() { return Promise.resolve(true); });
       var toastSpy = sandbox.spy(element, '_createToastAlert');
       var windowOpen = sandbox.stub(window, 'open');
@@ -116,11 +123,14 @@
         assert.isFalse(windowOpen.called);
         toast.fire('action');
         assert.isTrue(windowOpen.called);
-        assert.notEqual(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
+
+        // @see Issue 5822: noopener breaks closeAfterLogin
+        assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
             -1);
 
         var hideToastSpy = sandbox.spy(toast, 'hide');
 
+        element._handleWindowFocus();
         assert.isTrue(refreshStub.called);
         element.flushDebouncer('checkLoggedIn');
         flush(function() {
@@ -143,5 +153,86 @@
       assert.isTrue(element._showAlert.calledOnce);
       assert.isTrue(element._showAlert.lastCall.calledWithExactly('foo'));
     });
+
+    test('checks stale credentials on visibility change', function() {
+      var refreshStub = sandbox.stub(element.$.restAPI,
+          'checkCredentials');
+      sandbox.stub(Date, 'now').returns(999999);
+      element._lastCredentialCheck = 0;
+      element._handleVisibilityChange();
+
+      // Since there is no known account, it should not test credentials.
+      assert.isFalse(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 0);
+
+      element.knownAccountId = 123;
+      element._handleVisibilityChange();
+
+      // Should test credentials, since there is a known account.
+      assert.isTrue(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 999999);
+    });
+
+    test('refresh loop continues on credential fail', function(done) {
+      var accountPromise = Promise.resolve(null);
+      sandbox.stub(element.$.restAPI, 'checkCredentials')
+          .returns(accountPromise);
+      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      var handleRefreshStub = sandbox.stub(element,
+          '_handleCredentialRefreshed');
+      var reloadStub = sandbox.stub(element, '_reloadPage');
+
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      accountPromise.then(function() {
+        assert.isTrue(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+
+    test('refreshes with same credentials', function(done) {
+      var accountPromise = Promise.resolve({_account_id: 1234});
+      sandbox.stub(element.$.restAPI, 'checkCredentials')
+          .returns(accountPromise);
+      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      var handleRefreshStub = sandbox.stub(element,
+          '_handleCredentialRefreshed');
+      var reloadStub = sandbox.stub(element, '_reloadPage');
+
+      element.knownAccountId = 1234;
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      accountPromise.then(function() {
+        assert.isFalse(requestCheckStub.called);
+        assert.isTrue(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+
+    test('reloads when refreshed credentials differ', function(done) {
+      var accountPromise = Promise.resolve({_account_id: 1234});
+      sandbox.stub(element.$.restAPI, 'checkCredentials')
+          .returns(accountPromise);
+      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      var handleRefreshStub = sandbox.stub(element,
+          '_handleCredentialRefreshed');
+      var reloadStub = sandbox.stub(element, '_reloadPage');
+
+      element.knownAccountId = 4321; // Different from 1234
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      accountPromise.then(function() {
+        assert.isFalse(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isTrue(reloadStub.called);
+        done();
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index d4cd1e1..780f3b9 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -192,8 +192,8 @@
       delete this._baselines[name];
     },
 
-    reportInteraction: function(eventName) {
-      this.reporter(INTERACTION_TYPE, this.category, eventName);
+    reportInteraction: function(eventName, opt_msg) {
+      this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg);
     },
   });
 
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 379e9f9..214454a 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
@@ -356,7 +356,7 @@
     }
 
     var patchNum = this._comments.meta.patchRange.patchNum;
-    var isOnParent = comments[0].__isOnParent || false ;
+    var isOnParent = comments[0].side === 'PARENT' || false;
     if (line.type === GrDiffLine.Type.REMOVE ||
         opt_side === GrDiffBuilder.Side.LEFT) {
       if (this._comments.meta.patchRange.basePatchNum === 'PARENT') {
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 258b6fd..c19b643 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
@@ -59,7 +59,7 @@
             draft="[[comment.__draft]]"
             show-actions="[[_showActions]]"
             comment-side="[[comment.__commentSide]]"
-            is-on-parent="[[isOnParent]]"
+            side="[[comment.side]]"
             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 00c3277..37a0e2b 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
@@ -272,7 +272,7 @@
         __date: new Date(),
         path: this.path,
         patchNum: this.patchNum,
-        __isOnParent: this.__isOnParent,
+        side: this._getSide(this.isOnParent),
         __commentSide: this.commentSide,
       };
       if (opt_lineNum) {
@@ -289,6 +289,11 @@
       return d;
     },
 
+    _getSide: function(isOnParent) {
+      if (isOnParent) { return 'PARENT'; }
+      return 'REVISION';
+    },
+
     _handleCommentDiscard: function(e) {
       var diffCommentEl = Polymer.dom(e).rootTarget;
       var comment = diffCommentEl.comment;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index c49191b..0791193 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -137,12 +137,13 @@
       this.isRobotComment = !!comment.robot_id;
     },
 
+    isOnParent: function() {
+      return this.side === 'PARENT';
+    },
+
     save: function() {
       this.comment.message = this._messageText;
 
-      // Translate {Boolean} __isOnParent to {String} side for the REST API
-      // format.
-      this.comment.side = this.comment.__isOnParent ? 'PARENT' : null;
       this.disabled = true;
 
       this._eraseDraftComment();
@@ -153,7 +154,6 @@
 
         return this.$.restAPI.getResponseObject(response).then(function(obj) {
           var comment = obj;
-          comment.__isOnParent = comment.side === 'PARENT';
           comment.__draft = true;
           // Maintain the ephemeral draft ID for identification by other
           // elements.
@@ -407,18 +407,16 @@
     },
 
     _saveDraft: function(draft) {
-      draft.side = draft.__isOnParent ? 'PARENT' : null;
       return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft);
     },
 
     _deleteDraft: function(draft) {
-      draft.side = draft.__isOnParent ? 'PARENT' : null;
       return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
           draft);
     },
 
     _getPatchNum: function() {
-      return this.isOnParent ? 'PARENT' : this.patchNum;
+      return this.isOnParent() ? 'PARENT' : this.patchNum;
     },
 
     _loadLocalDraft: function(changeNum, patchNum, comment) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index d3faf76..2881fc1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -153,10 +153,10 @@
     });
 
     test('_getPatchNum', function() {
-      element.isOnParent = true;
+      element.side = 'PARENT';
       element.patchNum = 1;
       assert.equal(element._getPatchNum(), 'PARENT');
-      element.isOnParent = false;
+      element.side = 'REVISION';
       assert.equal(element._getPatchNum(), 1);
     });
 
@@ -508,7 +508,6 @@
         assert.deepEqual(fireStub.lastCall.args[1], {
           comment: {
             __commentSide: 'right',
-            __isOnParent: false,
             __draft: true,
             __draftID: 'temp_draft_id',
             __editing: false,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 05a7f72..52e869f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -32,6 +32,12 @@
      * @event line-selected
      */
 
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
     properties: {
       changeNum: String,
       noAutoRender: {
@@ -146,7 +152,10 @@
     addDraftAtLine: function(el) {
       this._selectLine(el);
       this._getLoggedIn().then(function(loggedIn) {
-        if (!loggedIn) { return; }
+        if (!loggedIn) {
+          this.fire('show-auth-required');
+          return;
+        }
 
         var value = el.getAttribute('data-value');
         if (value === GrDiffLine.FILE) {
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 9288e92..60cf798 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
@@ -60,6 +60,17 @@
         assert.isFalse(element.classList.contains('no-left'));
       });
 
+      test('addDraftAtLine', function(done) {
+        sandbox.stub(element, '_selectLine');
+        var loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addDraftAtLine();
+        flush(function() {
+          assert.isTrue(loggedInErrorSpy.called);
+          done();
+        });
+      });
+
       test('view does not start with displayLine classList', function() {
         assert.isFalse(
             element.$$('.diffContainer').classList.contains('displayLine'));
@@ -578,6 +589,20 @@
         });
       });
 
+      test('addDraftAtLine', function(done) {
+        var fakeLineEl = {getAttribute: sandbox.stub().returns(42)};
+        sandbox.stub(element, '_selectLine');
+        sandbox.stub(element, '_addDraft');
+        var loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addDraftAtLine(fakeLineEl);
+        flush(function() {
+          assert.isFalse(loggedInErrorSpy.called);
+          assert.isTrue(element._addDraft.calledWithExactly(fakeLineEl, 42));
+          done();
+        });
+      });
+
       suite('handle comment-update', function() {
 
         setup(function() {
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
index 113e37f..ba6973b 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
@@ -15,7 +15,11 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <dom-module id="gr-ranged-comment-layer">
+  <template>
+    <gr-reporting id="reporting" category="comments"></gr-reporting>
+  </template>
   <script src="../gr-diff-highlight/gr-annotation.js"></script>
   <script src="gr-ranged-comment-layer.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index bc13498..5300ef6 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -20,6 +20,8 @@
   var RANGE_HIGHLIGHT = 'range';
   var HOVER_HIGHLIGHT = 'rangeHighlight';
 
+  var NORMALIZE_RANGE_EVENT = 'normalize-range';
+
   Polymer({
     is: 'gr-ranged-comment-layer',
 
@@ -181,10 +183,13 @@
             // @see Issue 5744
             if (range.start >= range.end && range.start < line.text.length) {
               range.end = line.text.length;
+              this.$.reporting.reportInteraction(NORMALIZE_RANGE_EVENT,
+                  'Modified invalid comment range on l.' + lineNum +
+                  ' of the ' + side + ' side');
             }
 
             return range;
-          })
+          }.bind(this))
           .sort(function(a, b) {
             // Sort the ranges so that hovering highlights are on top.
             return a.hovering && !b.hovering ? 1 : 0;
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 3af9535..e78f77a 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -166,7 +166,7 @@
           on-close="_handleRegistrationDialogClose">
       </gr-registration-dialog>
     </gr-overlay>
-    <gr-error-manager></gr-error-manager>
+    <gr-error-manager id="errorManager"></gr-error-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
     <gr-router id="router"></gr-router>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 0874b5d..2e60f77 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -111,6 +111,7 @@
       // Preferences are cached when a user is logged in; warm them.
       this.$.restAPI.getPreferences();
       this.$.restAPI.getDiffPreferences();
+      this.$.errorManager.knownAccountId = this._account._account_id;
     },
 
     _viewChanged: function(view) {
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 7c8df3d..cad9bc3 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
@@ -334,9 +334,9 @@
       });
     },
 
-    refreshCredentials: function() {
-      this._cache = {};
-      return this.getLoggedIn();
+    checkCredentials: function() {
+      // Skip the REST response cache.
+      return this.fetchJSON('/accounts/self/detail');
     },
 
     getPreferences: function() {
@@ -781,7 +781,6 @@
       function onlyParent(c) { return c.side == PARENT_PATCH_NUM; }
       function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
       function setPath(c) { c.path = opt_path; }
-      function setIsOnParent(c) { c.__isOnParent = true; }
 
       var promises = [];
       var comments;
@@ -801,10 +800,6 @@
         if (opt_basePatchNum == PARENT_PATCH_NUM) {
           baseComments = comments.filter(onlyParent);
           baseComments.forEach(setPath);
-
-          // Translate {String} side to {Boolean} __isOnParent for readability
-          // in the code.
-          baseComments.forEach(setIsOnParent);
         }
         comments = comments.filter(withoutParent);
 
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 8eb2b13..2026183 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
@@ -150,7 +150,6 @@
           assert.equal(obj.baseComments.length, 1);
           assert.deepEqual(obj.baseComments[0], {
             side: 'PARENT',
-            __isOnParent: true,
             message: 'how did this work in the first place?',
             path: 'sieve.go',
             updated: '2017-02-03 22:33:28.000000000',
@@ -434,7 +433,7 @@
           });
     });
 
-    test('refreshCredentials', function(done) {
+    test('checkCredentials', function(done) {
       var responses = [
         {
           ok: false,
@@ -452,10 +451,11 @@
           return Promise.resolve(responses.shift());
         }
       });
-      element.getLoggedIn().then(function(isLoggedIn) {
-        assert.isFalse(isLoggedIn);
-        element.refreshCredentials().then(function(isRefreshed) {
-          assert.isTrue(isRefreshed);
+
+      element.getLoggedIn().then(function(account) {
+        assert.isNotOk(account);
+        element.checkCredentials().then(function(account) {
+          assert.isOk(account);
           done();
         });
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 1f7f85c..77d1c05 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -31,6 +31,10 @@
           return window.localStorage;
         },
       },
+      _exceededQuota: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     getDraftComment: function(location) {
@@ -94,7 +98,20 @@
     },
 
     _setObject: function(key, obj) {
-      this._storage.setItem(key, JSON.stringify(obj));
+      if (this._exceededQuota) { return; }
+      try {
+        this._storage.setItem(key, JSON.stringify(obj));
+      } catch (exc) {
+        // Catch for QuotaExceededError and disable writes on local storage the
+        // first time that it occurs.
+        if (exc.code === 22) {
+          this._exceededQuota = true;
+          console.warn('Local storage quota exceeded: disabling');
+          return;
+        } else {
+          throw exc;
+        }
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index f6c24cb..b17a9c9 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -31,19 +31,21 @@
 <script>
   suite('gr-storage tests', function() {
     var element;
-    var storage;
 
-    function cleanupStorage() {
-      // Make sure there are no entries in storage.
-      for (var key in window.localStorage) {
-        window.localStorage.removeItem(key);
-      }
+    function mockStorage(opt_quotaExceeded) {
+      return {
+        getItem: function(key) { return this[key]; },
+        removeItem: function(key) { delete this[key]; },
+        setItem: function(key, value) {
+          if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
+          this[key] = value;
+        },
+      };
     }
 
     setup(function() {
       element = fixture('basic');
-      storage = element._storage;
-      cleanupStorage();
+      element._storage = mockStorage();
     });
 
     test('storing, retrieving and erasing drafts', function() {
@@ -68,18 +70,16 @@
 
       // Setting the draft stores it under the expected key.
       element.setDraftComment(location, 'my comment');
-      assert.isOk(storage.getItem(key));
-      assert.equal(JSON.parse(storage.getItem(key)).message, 'my comment');
-      assert.isOk(JSON.parse(storage.getItem(key)).updated);
+      assert.isOk(element._storage.getItem(key));
+      assert.equal(JSON.parse(element._storage.getItem(key)).message,
+          'my comment');
+      assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
 
       // Erasing the draft removes the key.
       element.eraseDraftComment(location);
-      assert.isNotOk(storage.getItem(key));
-
-      cleanupStorage();
+      assert.isNotOk(element._storage.getItem(key));
     });
 
-
     test('automatically removes old drafts', function() {
       var changeNum = 1234;
       var patchNum = 5;
@@ -100,7 +100,7 @@
       var cleanupSpy = sinon.spy(element, '_cleanupDrafts');
 
       // Create a message with a timestamp that is a second behind the max age.
-      storage.setItem(key, JSON.stringify({
+      element._storage.setItem(key, JSON.stringify({
         message: 'old message',
         updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
       }));
@@ -110,10 +110,9 @@
 
       assert.isTrue(cleanupSpy.called);
       assert.isNotOk(draft);
-      assert.isNotOk(storage.getItem(key));
+      assert.isNotOk(element._storage.getItem(key));
 
       cleanupSpy.restore();
-      cleanupStorage();
     });
 
     test('_getDraftKey', function() {
@@ -138,5 +137,25 @@
       expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
       assert.equal(element._getDraftKey(location), expectedResult);
     });
+
+    test('exceeded quota disables storage', function() {
+      element._storage = mockStorage(true);
+      assert.isFalse(element._exceededQuota);
+
+      var changeNum = 1234;
+      var patchNum = 5;
+      var path = 'my_source_file.js';
+      var line = 123;
+      var location = {
+        changeNum: changeNum,
+        patchNum: patchNum,
+        path: path,
+        line: line,
+      };
+      var key = element._getDraftKey(location);
+      element.setDraftComment(location, 'my comment');
+      assert.isTrue(element._exceededQuota);
+      assert.isNotOk(element._storage.getItem(key));
+    });
   });
 </script>
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
index 4dcb9ff..deeb5d5 100644
--- a/tools/bzl/gwt.bzl
+++ b/tools/bzl/gwt.bzl
@@ -85,7 +85,7 @@
     "//gerrit-gwtexpui:CSS",
     "//lib:gwtjsonrpc",
     "//lib/gwt:dev",
-    "@jgit//jar:src",
+    "//lib/jgit/org.eclipse.jgit:jgit-source",
 ]
 
 USER_AGENT_XML = """<module rename-to='gerrit_ui'>
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 7ea0989..5e8d69a 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -56,6 +56,12 @@
 def retrieve_ext_location():
   return check_output(['bazel', 'info', 'output_base']).strip()
 
+def gen_bazel_path():
+  bazel = check_output(['which', 'bazel']).strip()
+  with open(path.join(ROOT, ".bazel_path"), 'w') as fd:
+    fd.write("bazel=%s\n" % bazel)
+    fd.write("PATH=%s\n" % environ["PATH"])
+
 def _query_classpath(target):
   deps = []
   t = cp_targets[target]
@@ -258,6 +264,7 @@
   gen_project(args.project_name)
   gen_classpath(ext_location)
   gen_factorypath(ext_location)
+  gen_bazel_path()
 
   # TODO(davido): Remove this when GWT gone
   gwt_working_dir = ".gwt_work_dir"
diff --git a/tools/maven/api.sh b/tools/maven/api.sh
index 8c441fb..e72e3cb 100755
--- a/tools/maven/api.sh
+++ b/tools/maven/api.sh
@@ -16,9 +16,9 @@
 
 set -e
 
-if [[ "$#" != "1" ]] ; then
+if [[ "$#" -lt "1" ]] ; then
   cat <<EOF
-Usage: run "$0 COMMAND" from the top of your workspace,
+Usage: run "$0 COMMAND [build_args...]" from the top of your workspace,
 where COMMAND is one of
 
   install
@@ -54,12 +54,13 @@
     exit 1
     ;;
 esac
+shift
 
 if [[ "${VERBOSE:-x}" != "x" ]]; then
   set -o xtrace
 fi
 
-bazel build //tools/maven:gen_${command} || \
+bazel build //tools/maven:gen_${command} "$@" || \
   { echo "bazel failed to build gen_${command}. Use VERBOSE=1 for more info" ; exit 1 ; }
 
 ./bazel-genfiles/tools/maven/${command}.sh
diff --git a/tools/version.py b/tools/version.py
index fee1477..fed6d5d 100755
--- a/tools/version.py
+++ b/tools/version.py
@@ -51,9 +51,5 @@
   pom = os.path.join(project, 'pom.xml')
   replace_in_file(pom, src_pattern)
 
-src_pattern = re.compile(r"^(GERRIT_VERSION = ')([-.\w]+)(')$", re.MULTILINE)
+src_pattern = re.compile(r'^(GERRIT_VERSION = ")([-.\w]+)(")$', re.MULTILINE)
 replace_in_file('version.bzl', src_pattern)
-
-src_pattern = re.compile(r'^(\s*-DarchetypeVersion=)([-.\w]+)(\s*\\)$',
-                         re.MULTILINE)
-replace_in_file(os.path.join('Documentation', 'dev-plugins.txt'), src_pattern)
diff --git a/version.bzl b/version.bzl
index 24a3c27..488a83f 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "2.14-SNAPSHOT"
+GERRIT_VERSION = "2.15-SNAPSHOT"