Merge "Add plugin API documentation for deprecated APIs"
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 87e2233..ba346e7 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -261,6 +261,33 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+=== Work In Progress State Changed
+
+Sent when the the link:intro-user.html#wip[WIP] state of the change has changed.
+
+type:: wip-state-changed
+
+change:: link:json.html#change[change attribute]
+
+changer:: link:json.html#account[account attribute]
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
+=== Private State Changed
+
+Sent when the the link:intro-user.html#private-changes[private] state of the
+change has changed.
+
+type:: private-state-changed
+
+change:: link:json.html#change[change attribute]
+
+changer:: link:json.html#account[account attribute]
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 === Vote Deleted
 
 Sent when a vote was removed from a change.
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 45bc045..91a837d 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1133,6 +1133,18 @@
 +
 Default is true.
 
+[[change.api.allowedIdentifier]]change.api.allowedIdentifier::
++
+Change identifier(s) that are allowed on the API. See
+link:rest-api-changes.html#change-id[Change Id] for more information.
++
+Possible values are `ALL`, `TRIPLET`, `NUMERIC_ID`, `I_HASH`, and
+`COMMIT_HASH` or any combination of those as a string list.
+`PROJECT_NUMERIC_ID` is always allowed and doesn't need to be listed
+explicitly.
++
+Default is `ALL`.
+
 [[change.cacheAutomerge]]change.cacheAutomerge::
 +
 When reviewing diff commits, the left-hand side shows the output of the
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 227651a0..84e4062 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -166,9 +166,7 @@
 [[label_layout]]
 === Layout
 
-Labels are laid out in the order they are specified in project.config,
-with inherited labels appearing first, providing some layout control to
-the administrator.
+Labels are laid out in alphabetical order.
 
 [[label_name]]
 === `label.Label-Name`
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 9991e2d..ff32a27 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -35,7 +35,7 @@
 === Gerrit Release WAR File
 
 To build the Gerrit web application that includes the GWT UI, the
-PolyGerrit UI and documentation:
+PolyGerrit UI, core plugins and documentation:
 
 ----
   bazel build release
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt
index c777327..5dacd71 100644
--- a/Documentation/dev-build-plugins.txt
+++ b/Documentation/dev-build-plugins.txt
@@ -95,6 +95,38 @@
   )
 ----
 
+=== Bundle custom plugin in release.war ===
+
+To bundle custom plugin(s) in the link:dev-bazel.html#release[release.war] artifact,
+add them to the CUSTOM_PLUGINS list in `tools/bzl/plugins.bzl`.
+
+Example of `tools/bzl/plugins.bzl` with custom plugin `my-plugin`:
+
+----
+CORE_PLUGINS = [
+    "commit-message-length-validator",
+    "download-commands",
+    "hooks",
+    "replication",
+    "reviewnotes",
+    "singleusergroup",
+]
+
+CUSTOM_PLUGINS = [
+    "my-plugin",
+]
+
+CUSTOM_PLUGINS_TEST_DEPS = [
+    # Add custom core plugins with tests deps here
+]
+----
+
+[NOTE]
+Since `tools/bzl/plugins.bzl` is part of Gerrit's source code and the version
+of the war is based on the state of the git repository that is built; you should
+commit this change before building, otherwise the version will be marked as
+'dirty'.
+
 == Bazel standalone driven
 
 Only few plugins support that mode for now:
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 9743779..6e39502 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -42,6 +42,13 @@
 Filters on a folder, they will be overwritten the next time you run
 `tools/eclipse/project.py`.
 
+=== Eclipse project with custom plugins ===
+
+To add custom plugins to the eclipse project add them to `tools/bzl/plugins.bzl`
+the same way you would when
+link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
+and run `tools/eclipse/project.py`.
+
 
 [[Formatting]]
 == Code Formatter Settings
diff --git a/Documentation/json.txt b/Documentation/json.txt
index 78b1bd7..f1dbe73 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -45,6 +45,12 @@
 
   ABANDONED;; Change was abandoned by its owner or administrator.
 
+private:: Boolean indicating if the change is
+link:intro-user.html#private-changes[private].
+
+wip:: Boolean indicating if the change is
+link:intro-user.html#wip[work in progress].
+
 comments:: All inline/file comments for this change in <<message,message attributes>>.
 
 trackingIds:: Issue tracking system links in
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 1cfd3e1..1606b8a 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5384,11 +5384,12 @@
 
 [[change-id]]
 === \{change-id\}
-Identifier that uniquely identifies one change.
+Identifier that uniquely identifies one change. It contains the URL-encoded
+project name as well as the change number: "'$$<project>~<numericId>$$'"
 
-This can be:
+Depending on the server's configuration, Gerrit can still support the following
+deprecated identifiers. These will be removed in a future release:
 
-* an ID of the change in the format "'$$<project>~<numericId>$$'"
 * an ID of the change in the format "'$$<project>~<branch>~<Change-Id>$$'",
   where for the branch the `refs/heads/` prefix can be omitted
   ("$$myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940$$")
@@ -5396,6 +5397,10 @@
   ("I8473b95934b5732ac55d26311a706c9c2bde9940")
 * a numeric change ID ("4247")
 
+If you need more time to migrate off of old change IDs, please see
+link:config-gerrit.html#change.api.allowedIdentifier[change.api.allowedIdentifier]
+for more information on how to enable the use of deprecated identifiers.
+
 [[comment-id]]
 === \{comment-id\}
 UUID of a published comment.
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index bb0f343..6e02786 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1386,9 +1386,6 @@
 |`large_change`       ||
 link:config-gerrit.html#change.largeChange[Number of changed lines from
 which on a change is considered as a large change].
-|`private_by_default` |not set if `false`|
-Returns true if changes are by default created as private.
-See link:config-gerrit.html#change.privateByDefault[privateByDefault]
 |`reply_label`        ||
 link:config-gerrit.html#change.replyTooltip[Label name for the reply
 button].
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index c89163b..fec430f 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1165,12 +1165,14 @@
 
   {
     "remove": [
-      "refs/*": {
-        "permissions": {
-          "read": {
-            "rules": {
-              "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
-                "action": "ALLOW"
+      {
+        "refs/*": {
+          "permissions": {
+            "read": {
+              "rules": {
+                "c2ce4749a32ceb82cd6adcce65b8216e12afb41c": {
+                  "action": "ALLOW"
+                }
               }
             }
           }
diff --git a/WORKSPACE b/WORKSPACE
index 1186db8..3ef1265 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -414,8 +414,8 @@
 
 maven_jar(
     name = "auto_value",
-    artifact = "com.google.auto.value:auto-value:1.4.1",
-    sha1 = "8172ebbd7970188aff304c8a420b9f17168f6f48",
+    artifact = "com.google.auto.value:auto-value:1.5.3",
+    sha1 = "514df6a7c7938de35c7f68dc8b8f22df86037f38",
 )
 
 maven_jar(
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index 039948d..b57545b 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.changes.ChangeList;
 import com.google.gerrit.client.changes.CommentInfo;
+import com.google.gerrit.client.changes.QueryScreen;
 import com.google.gerrit.client.changes.RevisionInfoCache;
 import com.google.gerrit.client.changes.StarredChanges;
 import com.google.gerrit.client.changes.Util;
@@ -282,11 +283,46 @@
   @Override
   protected void onLoad() {
     super.onLoad();
+    loadChangeScreen();
+  }
+
+  private void loadChangeScreen() {
+    if (project == null) {
+      // Load the project if it is not already present. This is the case when the user used a URL
+      // that doesn't include the project. Setting it here will rewrite the URL token to include the
+      // project (visible to the user) and all future API calls made from the change screen will use
+      // project/+/changeId to identify the change.
+      String query = "change:" + changeId.get();
+      ChangeList.query(
+          query,
+          Collections.emptySet(),
+          new AsyncCallback<ChangeList>() {
+            @Override
+            public void onSuccess(ChangeList result) {
+              if (result.length() == 0) {
+                Gerrit.display(getToken(), new NotFoundScreen());
+              } else if (result.length() > 1) {
+                Gerrit.display(PageLinks.toChangeQuery(query), QueryScreen.forQuery(query));
+              } else {
+                // Initialize current screen with newly obtained project
+                project = result.get(0).projectNameKey();
+                loadChangeScreen();
+              }
+            }
+
+            @Override
+            public void onFailure(Throwable caught) {
+              GerritCallback.showFailure(caught);
+            }
+          });
+
+      return;
+    }
     CallbackGroup group = new CallbackGroup();
     if (Gerrit.isSignedIn()) {
       ChangeList.query(
           "change:" + changeId.get() + " has:draft",
-          Collections.<ListChangesOption>emptySet(),
+          Collections.emptySet(),
           group.add(
               new AsyncCallback<ChangeList>() {
                 @Override
@@ -318,15 +354,6 @@
               @Override
               public void onSuccess(ChangeInfo info) {
                 info.init();
-                if (project == null) {
-                  // Update Project when the first API call succeeded if it wasn't already present.
-                  // This is the case when the user used a URL that doesn't include the project.
-                  // Setting it here will rewrite the URL token to include the project (visible to
-                  // the user) and all future API calls made from the change screen will use
-                  // project/+/changeId to identify the change.
-                  project = info.projectNameKey();
-                }
-
                 initCurrentRevision(info);
                 final RevisionInfo rev = info.revision(revision);
                 CallbackGroup group = new CallbackGroup();
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 693f296..ef42ad3 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -119,12 +119,7 @@
 
       accountsUpdate
           .create()
-          .insert(
-              id,
-              a -> {
-                a.setFullName(fullName);
-                a.setPreferredEmail(email);
-              });
+          .insert("Create Test Account", id, u -> u.setFullName(fullName).setPreferredEmail(email));
 
       if (groupNames != null) {
         for (String n : groupNames) {
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index 7bca1e8..b710121 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -19,7 +19,6 @@
   public Boolean showAssigneeInChangesTable;
   public Boolean allowDrafts;
   public int largeChange;
-  public Boolean privateByDefault;
   public String replyLabel;
   public String replyTooltip;
   public int updateDelay;
diff --git a/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
new file mode 100644
index 0000000..e46ceb8
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/PrivateStateChangedListener.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+public interface PrivateStateChangedListener {
+  interface Event extends ChangeEvent {}
+
+  void onPrivateStateChanged(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
new file mode 100644
index 0000000..e957421
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/WorkInProgressStateChangedListener.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+public interface WorkInProgressStateChangedListener {
+  interface Event extends ChangeEvent {}
+
+  void onWorkInProgressStateChanged(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/restapi/DeprecatedIdentifierException.java b/java/com/google/gerrit/extensions/restapi/DeprecatedIdentifierException.java
new file mode 100644
index 0000000..aa28cfc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/DeprecatedIdentifierException.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.restapi;
+
+/** Named resource was accessed using a deprecated identifier. */
+public class DeprecatedIdentifierException extends BadRequestException {
+  private static final long serialVersionUID = 1L;
+
+  /** Requested resource using a deprecated identifier. */
+  public DeprecatedIdentifierException(String msg) {
+    super(msg);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index 65943fc..d7cbdb8 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -15,6 +15,7 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/http",
         "//java/com/google/gwtexpui/linker:server",
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index 936044d..9624241 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -20,6 +20,7 @@
         "//java/com/google/gerrit/server/api",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/sshd",
         "//lib:guava",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index b5995a8..5b9cf3b 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -55,7 +55,6 @@
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerConfigModule;
-import com.google.gerrit.server.config.RestCacheAdminModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.events.EventBroker;
 import com.google.gerrit.server.events.StreamEventsApiListener;
@@ -79,6 +78,7 @@
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.restapi.config.RestCacheAdminModule;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
diff --git a/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java b/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
index 87df4cf..4d56036 100644
--- a/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/ConfigRestApiServlet.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.restapi;
 
-import com.google.gerrit.server.config.ConfigCollection;
+import com.google.gerrit.server.restapi.config.ConfigCollection;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index ce8741c..5b24284 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -15,6 +15,7 @@
 // WARNING: NoteDbUpdateManager cares about the package name RestApiServlet lives in.
 package com.google.gerrit.httpd.restapi;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
@@ -1210,6 +1211,7 @@
       RequestUtil.setErrorTraceAttribute(req, err);
     }
     configureCaching(req, res, null, null, c);
+    checkArgument(statusCode >= 400, "non-error status: %s", statusCode);
     res.setStatus(statusCode);
     return replyText(req, res, msg);
   }
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 3dce217..a255020 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -38,6 +38,7 @@
         "//java/com/google/gerrit/server/api",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/sshd",
         "//java/com/google/gwtexpui/linker:server",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 16d81b7..4bc06d0 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -64,7 +64,6 @@
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.RestCacheAdminModule;
 import com.google.gerrit.server.events.EventBroker;
 import com.google.gerrit.server.events.StreamEventsApiListener;
 import com.google.gerrit.server.git.GarbageCollectionModule;
@@ -88,6 +87,7 @@
 import com.google.gerrit.server.plugins.PluginRestApiModule;
 import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.restapi.config.RestCacheAdminModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 385f198..d00b945 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -17,20 +17,28 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
+import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsBatchUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
-import com.google.inject.AbstractModule;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import com.google.inject.Provider;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.Locale;
 import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 
 /** Converts the local username for all accounts to lower case */
@@ -38,10 +46,12 @@
   private final LifecycleManager manager = new LifecycleManager();
   private final TextProgressMonitor monitor = new TextProgressMonitor();
 
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private AllUsersName allUsersName;
+  @Inject private Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
+  @Inject private ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
   @Inject private ExternalIds externalIds;
 
-  @Inject private ExternalIdsBatchUpdate externalIdsBatchUpdate;
-
   @Override
   public int run() throws Exception {
     Injector dbInjector = createDbInjector(MULTI_USER);
@@ -49,9 +59,12 @@
     manager.start();
     dbInjector
         .createChildInjector(
-            new AbstractModule() {
+            new FactoryModule() {
               @Override
               protected void configure() {
+                bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+                factory(MetaDataUpdate.InternalFactory.class);
+
                 // The LocalUsernamesToLowerCase program needs to access all external IDs only
                 // once to update them. After the update they are not accessed again. Hence the
                 // LocalUsernamesToLowerCase program doesn't benefit from caching external IDs and
@@ -64,12 +77,18 @@
     Collection<ExternalId> todo = externalIds.all();
     monitor.beginTask("Converting local usernames", todo.size());
 
-    for (ExternalId extId : todo) {
-      convertLocalUserToLowerCase(extId);
-      monitor.update(1);
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
+      for (ExternalId extId : todo) {
+        convertLocalUserToLowerCase(extIdNotes, extId);
+        monitor.update(1);
+      }
+      try (MetaDataUpdate metaDataUpdate = metaDataUpdateServerFactory.get().create(allUsersName)) {
+        metaDataUpdate.setMessage("Convert local usernames to lower case");
+        extIdNotes.commit(metaDataUpdate);
+      }
     }
 
-    externalIdsBatchUpdate.commit("Convert local usernames to lower case");
     monitor.endTask();
 
     int exitCode = reindexAccounts();
@@ -77,7 +96,8 @@
     return exitCode;
   }
 
-  private void convertLocalUserToLowerCase(ExternalId extId) {
+  private void convertLocalUserToLowerCase(ExternalIdNotes extIdNotes, ExternalId extId)
+      throws OrmDuplicateKeyException, IOException {
     if (extId.isScheme(SCHEME_GERRIT)) {
       String localUser = extId.key().id();
       String localUserLowerCase = localUser.toLowerCase(Locale.US);
@@ -89,7 +109,7 @@
                 extId.accountId(),
                 extId.email(),
                 extId.password());
-        externalIdsBatchUpdate.replace(extId, extIdLowerCase);
+        extIdNotes.replace(extId, extIdLowerCase);
       }
     }
   }
diff --git a/java/com/google/gerrit/pgm/MigrateToNoteDb.java b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
index 59511a2..8cd148f 100644
--- a/java/com/google/gerrit/pgm/MigrateToNoteDb.java
+++ b/java/com/google/gerrit/pgm/MigrateToNoteDb.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.index.DummyIndexModule;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -145,7 +146,12 @@
     // their server is offline.
     List<String> reindexArgs =
         ImmutableList.of(
-            "--site-path", getSitePath().toString(), "--threads", Integer.toString(threads));
+            "--site-path",
+            getSitePath().toString(),
+            "--threads",
+            Integer.toString(threads),
+            "--index",
+            ChangeSchemaDefinitions.NAME);
     System.out.println("Migration complete, reindexing changes with:");
     System.out.println("  reindex " + reindexArgs.stream().collect(joining(" ")));
     Reindex reindexPgm = new Reindex();
diff --git a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
index 35beaeb..260f695 100644
--- a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
+++ b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
@@ -148,7 +148,7 @@
   @Override
   public void destroy() {}
 
-  private final class Listener implements ContinuationListener {
+  private static final class Listener implements ContinuationListener {
     final Future<?> future;
 
     Listener(Future<?> future) {
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index 2beb50a..fbe9b62 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.account.AccountConfig;
 import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import java.io.File;
@@ -69,7 +70,14 @@
                 new GerritPersonIdentProvider(flags.cfg).get(), account.getRegisteredOn());
 
         Config accountConfig = new Config();
-        AccountConfig.writeToConfig(account, accountConfig);
+        AccountConfig.writeToConfig(
+            InternalAccountUpdate.builder()
+                .setActive(account.isActive())
+                .setFullName(account.getFullName())
+                .setPreferredEmail(account.getPreferredEmail())
+                .setStatus(account.getStatus())
+                .build(),
+            accountConfig);
 
         DirCache newTree = DirCache.newInCore();
         DirCacheEditor editor = newTree.editor();
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index ab491f7c..d2a9b04 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -19,10 +19,10 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdReader;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.File;
@@ -31,13 +31,9 @@
 import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.FS;
 
 public class ExternalIdsOnInit {
@@ -54,32 +50,20 @@
 
   public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
       throws OrmException, IOException, ConfigInvalidException {
-
     File path = getPath();
     if (path != null) {
-      try (Repository repo = new FileRepository(path);
-          RevWalk rw = new RevWalk(repo);
-          ObjectInserter ins = repo.newObjectInserter()) {
-        ObjectId rev = ExternalIdReader.readRevision(repo);
-
-        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-        for (ExternalId extId : extIds) {
-          ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
+      try (Repository allUsersRepo = new FileRepository(path)) {
+        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsersRepo);
+        extIdNotes.insert(extIds);
+        try (MetaDataUpdate metaDataUpdate =
+            new MetaDataUpdate(
+                GitReferenceUpdated.DISABLED, new Project.NameKey(allUsers), allUsersRepo)) {
+          PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
+          metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
+          metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
+          metaDataUpdate.getCommitBuilder().setMessage(commitMessage);
+          extIdNotes.commit(metaDataUpdate);
         }
-
-        PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
-        ExternalIdsUpdate.commit(
-            new Project.NameKey(allUsers),
-            repo,
-            rw,
-            ins,
-            rev,
-            noteMap,
-            commitMessage,
-            serverIdent,
-            serverIdent,
-            null,
-            GitReferenceUpdated.DISABLED);
       }
     }
   }
diff --git a/java/com/google/gerrit/reviewdb/client/Comment.java b/java/com/google/gerrit/reviewdb/client/Comment.java
index 4b3c652..3d19da4 100644
--- a/java/com/google/gerrit/reviewdb/client/Comment.java
+++ b/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -214,6 +214,12 @@
   public String serverId;
   public boolean unresolved;
 
+  /**
+   * Whether the comment was parsed from a JSON representation (false) or the legacy custom notes
+   * format (true).
+   */
+  public transient boolean legacyFormat;
+
   public Comment(Comment c) {
     this(
         new Key(c.key),
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 6052a63..fb1fc28 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -100,6 +100,7 @@
         ":server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/git/receive",
+        "//java/com/google/gerrit/server/restapi",
         "//lib:blame-cache",
         "//lib:guava",
         "//lib:soy",
diff --git a/java/com/google/gerrit/server/ChangeFinder.java b/java/com/google/gerrit/server/ChangeFinder.java
index 4b7cbac..cb82778 100644
--- a/java/com/google/gerrit/server/ChangeFinder.java
+++ b/java/com/google/gerrit/server/ChangeFinder.java
@@ -17,8 +17,10 @@
 import com.google.common.base.Throwables;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
@@ -30,6 +32,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.change.ChangeTriplet;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -46,6 +50,7 @@
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class ChangeFinder {
@@ -60,10 +65,11 @@
     };
   }
 
-  private enum ChangeIdType {
+  public enum ChangeIdType {
+    ALL,
     TRIPLET,
     NUMERIC_ID,
-    CHANGE_ID,
+    I_HASH,
     PROJECT_NUMERIC_ID,
     COMMIT_HASH
   }
@@ -74,6 +80,7 @@
   private final Provider<ReviewDb> reviewDb;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Counter1<ChangeIdType> changeIdCounter;
+  private final ImmutableSet<ChangeIdType> allowedIdTypes;
 
   @Inject
   ChangeFinder(
@@ -82,7 +89,8 @@
       Provider<InternalChangeQuery> queryProvider,
       Provider<ReviewDb> reviewDb,
       ChangeNotes.Factory changeNotesFactory,
-      MetricMaker metricMaker) {
+      MetricMaker metricMaker,
+      @GerritServerConfig Config config) {
     this.indexConfig = indexConfig;
     this.changeIdProjectCache = changeIdProjectCache;
     this.queryProvider = queryProvider;
@@ -95,16 +103,41 @@
                 .setRate()
                 .setUnit("requests"),
             Field.ofEnum(ChangeIdType.class, "change_id_type"));
+    List<ChangeIdType> configuredChangeIdTypes =
+        ConfigUtil.getEnumList(config, "change", "api", "allowedIdentifier", ChangeIdType.ALL);
+    // Ensure that PROJECT_NUMERIC_ID can't be removed
+    configuredChangeIdTypes.add(ChangeIdType.PROJECT_NUMERIC_ID);
+    this.allowedIdTypes = ImmutableSet.copyOf(configuredChangeIdTypes);
   }
 
   /**
    * Find changes matching the given identifier.
    *
-   * @param id change identifier, either a numeric ID, a Change-Id, or project~branch~id triplet.
+   * @param id change identifier.
    * @return possibly-empty list of notes for all matching changes; may or may not be visible.
    * @throws OrmException if an error occurred querying the database.
    */
   public List<ChangeNotes> find(String id) throws OrmException {
+    try {
+      return find(id, false);
+    } catch (DeprecatedIdentifierException e) {
+      // This can't happen because we don't enforce deprecation
+      throw new OrmException(e);
+    }
+  }
+
+  /**
+   * Find changes matching the given identifier.
+   *
+   * @param id change identifier.
+   * @param enforceDeprecation boolean to see if we should throw {@link
+   *     DeprecatedIdentifierException} in case the identifier is deprecated
+   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
+   * @throws OrmException if an error occurred querying the database
+   * @throws DeprecatedIdentifierException if the identifier is deprecated.
+   */
+  public List<ChangeNotes> find(String id, boolean enforceDeprecation)
+      throws OrmException, DeprecatedIdentifierException {
     if (id.isEmpty()) {
       return Collections.emptyList();
     }
@@ -115,7 +148,7 @@
       // Try project~numericChangeId
       Integer n = Ints.tryParse(id.substring(z + 1));
       if (n != null) {
-        changeIdCounter.increment(ChangeIdType.PROJECT_NUMERIC_ID);
+        checkIdType(ChangeIdType.PROJECT_NUMERIC_ID, enforceDeprecation, n.toString());
         return fromProjectNumber(id.substring(0, z), n.intValue());
       }
     }
@@ -124,7 +157,7 @@
       // Try numeric changeId
       Integer n = Ints.tryParse(id);
       if (n != null) {
-        changeIdCounter.increment(ChangeIdType.NUMERIC_ID);
+        checkIdType(ChangeIdType.NUMERIC_ID, enforceDeprecation, n.toString());
         return find(new Change.Id(n));
       }
     }
@@ -135,7 +168,7 @@
 
     // Try commit hash
     if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
-      changeIdCounter.increment(ChangeIdType.COMMIT_HASH);
+      checkIdType(ChangeIdType.COMMIT_HASH, enforceDeprecation, id);
       return asChangeNotes(query.byCommit(id));
     }
 
@@ -144,7 +177,7 @@
       Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id, y, z);
       if (triplet.isPresent()) {
         ChangeTriplet t = triplet.get();
-        changeIdCounter.increment(ChangeIdType.TRIPLET);
+        checkIdType(ChangeIdType.TRIPLET, enforceDeprecation, triplet.get().toString());
         return asChangeNotes(query.byBranchKey(t.branch(), t.id()));
       }
     }
@@ -152,7 +185,7 @@
     // Try isolated Ihash... format ("Change-Id: Ihash").
     List<ChangeNotes> notes = asChangeNotes(query.byKeyPrefix(id));
     if (!notes.isEmpty()) {
-      changeIdCounter.increment(ChangeIdType.CHANGE_ID);
+      checkIdType(ChangeIdType.I_HASH, enforceDeprecation, id);
     }
     return notes;
   }
@@ -222,4 +255,18 @@
     }
     return notes;
   }
+
+  private void checkIdType(ChangeIdType type, boolean enforceDeprecation, String val)
+      throws DeprecatedIdentifierException {
+    if (enforceDeprecation
+        && !allowedIdTypes.contains(ChangeIdType.ALL)
+        && !allowedIdTypes.contains(type)) {
+      throw new DeprecatedIdentifierException(
+          String.format(
+              "The provided change identifier %s is deprecated. "
+                  + "Use 'project~changeNumber' instead.",
+              val));
+    }
+    changeIdCounter.increment(type);
+  }
 }
diff --git a/java/com/google/gerrit/server/ReviewersUtil.java b/java/com/google/gerrit/server/ReviewersUtil.java
index c566e59..a8410d8 100644
--- a/java/com/google/gerrit/server/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/ReviewersUtil.java
@@ -18,36 +18,41 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.extensions.common.GroupBaseInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.SuggestReviewers;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.account.AccountPredicates;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
-import com.google.gerrit.server.query.account.AccountQueryProcessor;
 import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -108,30 +113,36 @@
 
   private final AccountLoader accountLoader;
   private final AccountQueryBuilder accountQueryBuilder;
-  private final Provider<AccountQueryProcessor> queryProvider;
   private final GroupBackend groupBackend;
   private final GroupMembers groupMembers;
   private final ReviewerRecommender reviewerRecommender;
   private final Metrics metrics;
+  private final AccountIndexCollection accountIndexes;
+  private final IndexConfig indexConfig;
+  private final AccountControl.Factory accountControlFactory;
 
   @Inject
   ReviewersUtil(
       AccountLoader.Factory accountLoaderFactory,
       AccountQueryBuilder accountQueryBuilder,
-      Provider<AccountQueryProcessor> queryProvider,
       GroupBackend groupBackend,
       GroupMembers groupMembers,
       ReviewerRecommender reviewerRecommender,
-      Metrics metrics) {
+      Metrics metrics,
+      AccountIndexCollection accountIndexes,
+      IndexConfig indexConfig,
+      AccountControl.Factory accountControlFactory) {
     Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
     fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
     this.accountLoader = accountLoaderFactory.create(fillOptions);
     this.accountQueryBuilder = accountQueryBuilder;
-    this.queryProvider = queryProvider;
     this.groupBackend = groupBackend;
     this.groupMembers = groupMembers;
     this.reviewerRecommender = reviewerRecommender;
     this.metrics = metrics;
+    this.accountIndexes = accountIndexes;
+    this.indexConfig = indexConfig;
+    this.accountControlFactory = accountControlFactory;
   }
 
   public interface VisibilityControl {
@@ -167,7 +178,9 @@
         if (filteredRecommendations.size() >= limit) {
           break;
         }
-        if (visibilityControl.isVisibleTo(reviewer)) {
+        // Check if change is visible to reviewer and if the current user can see reviewer
+        if (visibilityControl.isVisibleTo(reviewer)
+            && accountControlFactory.get().canSee(reviewer)) {
           filteredRecommendations.add(reviewer);
         }
       }
@@ -191,14 +204,27 @@
   private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers) throws OrmException {
     try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
       try {
-        QueryResult<AccountState> result =
-            queryProvider
-                .get()
-                .setUserProvidedLimit(suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER)
-                .query(
-                    AccountPredicates.andActive(
-                        accountQueryBuilder.defaultQuery(suggestReviewers.getQuery())));
-        return result.entities().stream().map(a -> a.getAccount().getId()).collect(toList());
+        // For performance reasons we don't use AccountQueryProvider as it would always load the
+        // complete account from the cache (or worse, from NoteDb) even though we only need the ID
+        // which we can directly get from the returned results.
+        ResultSet<FieldBundle> result =
+            accountIndexes
+                .getSearchIndex()
+                .getSource(
+                    Predicate.and(
+                        AccountPredicates.isActive(),
+                        accountQueryBuilder.defaultQuery(suggestReviewers.getQuery())),
+                    QueryOptions.create(
+                        indexConfig,
+                        0,
+                        suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER,
+                        ImmutableSet.of(AccountField.ID.getName())))
+                .readRaw();
+        return result
+            .toList()
+            .stream()
+            .map(f -> new Account.Id(f.getValue(AccountField.ID).intValue()))
+            .collect(toList());
       } catch (QueryParseException e) {
         return ImmutableList.of();
       }
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 6bc6a21..1283270 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -80,6 +80,7 @@
   private final String ref;
 
   private Optional<Account> loadedAccount;
+  private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
   private Timestamp registeredOn;
   private List<ValidationError> validationErrors;
 
@@ -117,6 +118,14 @@
   public void setAccount(Account account) {
     checkLoaded();
     this.loadedAccount = Optional.of(account);
+    this.accountUpdate =
+        Optional.of(
+            InternalAccountUpdate.builder()
+                .setActive(account.isActive())
+                .setFullName(account.getFullName())
+                .setPreferredEmail(account.getPreferredEmail())
+                .setStatus(account.getStatus())
+                .build());
     this.registeredOn = account.getRegisteredOn();
   }
 
@@ -127,15 +136,29 @@
    * @throws OrmDuplicateKeyException if the user branch already exists
    */
   public Account getNewAccount() throws OrmDuplicateKeyException {
+    return getNewAccount(TimeUtil.nowTs());
+  }
+
+  /**
+   * Creates a new account.
+   *
+   * @return the new account
+   * @throws OrmDuplicateKeyException if the user branch already exists
+   */
+  Account getNewAccount(Timestamp registeredOn) throws OrmDuplicateKeyException {
     checkLoaded();
     if (revision != null) {
       throw new OrmDuplicateKeyException(String.format("account %s already exists", accountId));
     }
-    this.registeredOn = TimeUtil.nowTs();
+    this.registeredOn = registeredOn;
     this.loadedAccount = Optional.of(new Account(accountId, registeredOn));
     return loadedAccount.get();
   }
 
+  public void setAccountUpdate(InternalAccountUpdate accountUpdate) {
+    this.accountUpdate = Optional.of(accountUpdate);
+  }
+
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     if (revision != null) {
@@ -186,24 +209,39 @@
     }
 
     if (revision != null) {
-      commit.setMessage("Update account\n");
+      if (Strings.isNullOrEmpty(commit.getMessage())) {
+        commit.setMessage("Update account\n");
+      }
     } else {
-      commit.setMessage("Create account\n");
+      if (Strings.isNullOrEmpty(commit.getMessage())) {
+        commit.setMessage("Create account\n");
+      }
+
       commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
       commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
     }
 
     Config cfg = readConfig(ACCOUNT_CONFIG);
-    writeToConfig(loadedAccount.get(), cfg);
+    if (accountUpdate.isPresent()) {
+      writeToConfig(accountUpdate.get(), cfg);
+    }
     saveConfig(ACCOUNT_CONFIG, cfg);
+
+    // metaId is set in the commit(MetaDataUpdate) method after the commit is created
+    loadedAccount = Optional.of(parse(cfg, null));
+
+    accountUpdate = Optional.empty();
+
     return true;
   }
 
-  public static void writeToConfig(Account account, Config cfg) {
-    setActive(cfg, account.isActive());
-    set(cfg, KEY_FULL_NAME, account.getFullName());
-    set(cfg, KEY_PREFERRED_EMAIL, account.getPreferredEmail());
-    set(cfg, KEY_STATUS, account.getStatus());
+  public static void writeToConfig(InternalAccountUpdate accountUpdate, Config cfg) {
+    accountUpdate.getActive().ifPresent(active -> setActive(cfg, active));
+    accountUpdate.getFullName().ifPresent(fullName -> set(cfg, KEY_FULL_NAME, fullName));
+    accountUpdate
+        .getPreferredEmail()
+        .ifPresent(preferredEmail -> set(cfg, KEY_PREFERRED_EMAIL, preferredEmail));
+    accountUpdate.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index b3e210d..3f87e5f 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -29,6 +29,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountsUpdate.AccountUpdater;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
@@ -203,7 +205,7 @@
   private void update(AuthRequest who, ExternalId extId)
       throws OrmException, IOException, ConfigInvalidException {
     IdentifiedUser user = userFactory.create(extId.accountId());
-    List<Consumer<Account>> accountUpdates = new ArrayList<>();
+    List<Consumer<InternalAccountUpdate.Builder>> accountUpdates = new ArrayList<>();
 
     // If the email address was modified by the authentication provider,
     // update our records to match the changed email.
@@ -212,19 +214,20 @@
     String oldEmail = extId.email();
     if (newEmail != null && !newEmail.equals(oldEmail)) {
       if (oldEmail != null && oldEmail.equals(user.getAccount().getPreferredEmail())) {
-        accountUpdates.add(a -> a.setPreferredEmail(newEmail));
+        accountUpdates.add(u -> u.setPreferredEmail(newEmail));
       }
 
-      externalIdsUpdateFactory
-          .create()
-          .replace(
-              extId, ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password()));
+      accountUpdates.add(
+          u ->
+              u.replaceExternalId(
+                  extId,
+                  ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password())));
     }
 
     if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
         && !Strings.isNullOrEmpty(who.getDisplayName())
         && !eq(user.getAccount().getFullName(), who.getDisplayName())) {
-      accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
+      accountUpdates.add(u -> u.setFullName(who.getDisplayName()));
     }
 
     if (!realm.allowsEdit(AccountFieldName.USER_NAME)
@@ -236,7 +239,13 @@
     }
 
     if (!accountUpdates.isEmpty()) {
-      Account account = accountsUpdateFactory.create().update(user.getAccountId(), accountUpdates);
+      Account account =
+          accountsUpdateFactory
+              .create()
+              .update(
+                  "Update Account on Login",
+                  user.getAccountId(),
+                  AccountUpdater.joinConsumers(accountUpdates));
       if (account == null) {
         throw new OrmException("Account " + user.getAccountId() + " has been deleted");
       }
@@ -258,27 +267,23 @@
 
     Account account;
     try {
-      AccountsUpdate accountsUpdate = accountsUpdateFactory.create();
       account =
-          accountsUpdate.insert(
-              newId,
-              a -> {
-                a.setFullName(who.getDisplayName());
-                a.setPreferredEmail(extId.email());
-              });
-
-      ExternalId existingExtId = externalIds.get(extId.key());
-      if (existingExtId != null && !existingExtId.accountId().equals(extId.accountId())) {
-        // external ID is assigned to another account, do not overwrite
-        accountsUpdate.delete(account);
-        throw new AccountException(
-            "Cannot assign external ID \""
-                + extId.key().get()
-                + "\" to account "
-                + newId
-                + "; external ID already in use.");
-      }
-      externalIdsUpdateFactory.create().upsert(extId);
+          accountsUpdateFactory
+              .create()
+              .insert(
+                  "Create Account on First Login",
+                  newId,
+                  u ->
+                      u.setFullName(who.getDisplayName())
+                          .setPreferredEmail(extId.email())
+                          .addExternalId(extId));
+    } catch (DuplicateExternalIdKeyException e) {
+      throw new AccountException(
+          "Cannot assign external ID \""
+              + e.getDuplicateKey().get()
+              + "\" to account "
+              + newId
+              + "; external ID already in use.");
     } finally {
       // If adding the account failed, it may be that it actually was the
       // first account. So we reset the 'check for first account'-guard, as
@@ -308,7 +313,7 @@
       // Only set if the name hasn't been used yet, but was given to us.
       //
       try {
-        changeUserNameFactory.create(user, who.getUserName()).call();
+        changeUserNameFactory.create("Set Username on Login", user, who.getUserName()).call();
       } catch (NameAlreadyUsedException e) {
         String message =
             "Cannot assign user name \""
@@ -407,23 +412,19 @@
       }
       update(who, extId);
     } else {
-      externalIdsUpdateFactory
+      accountsUpdateFactory
           .create()
-          .insert(ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
-
-      if (who.getEmailAddress() != null) {
-        accountsUpdateFactory
-            .create()
-            .update(
-                to,
-                a -> {
-                  if (a.getPreferredEmail() == null) {
-                    a.setPreferredEmail(who.getEmailAddress());
-                  }
-                });
-      }
+          .update(
+              "Link External ID",
+              to,
+              (a, u) -> {
+                u.addExternalId(
+                    ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
+                if (who.getEmailAddress() != null && a.getPreferredEmail() == null) {
+                  u.setPreferredEmail(who.getEmailAddress());
+                }
+              });
     }
-
     return new AuthResult(to, who.getExternalIdKey(), false);
   }
 
@@ -503,12 +504,16 @@
       accountsUpdateFactory
           .create()
           .update(
+              "Clear Preferred Email on Unlinking External ID\n"
+                  + "\n"
+                  + "The preferred email is cleared because the corresponding external ID\n"
+                  + "was removed.",
               from,
-              a -> {
+              (a, u) -> {
                 if (a.getPreferredEmail() != null) {
                   for (ExternalId extId : extIds) {
                     if (a.getPreferredEmail().equals(extId.email())) {
-                      a.setPreferredEmail(null);
+                      u.setPreferredEmail(null);
                       break;
                     }
                   }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index e0f17dd..f88f7e2 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -15,29 +15,40 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 
-import com.google.common.collect.ImmutableList;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -64,6 +75,38 @@
 @Singleton
 public class AccountsUpdate {
   /**
+   * Updater for an account.
+   *
+   * <p>Allows to read the current state of an account and to prepare updates to it.
+   */
+  @FunctionalInterface
+  public static interface AccountUpdater {
+    /**
+     * Prepare updates to an account.
+     *
+     * <p>Use the provided account only to read the current state of the account. Don't do updates
+     * to the account. For updates use the provided account update builder.
+     *
+     * @param account the account that is being updated
+     * @param update account update builder
+     */
+    void update(Account account, InternalAccountUpdate.Builder update);
+
+    public static AccountUpdater join(List<AccountUpdater> updaters) {
+      return (a, u) -> updaters.stream().forEach(updater -> updater.update(a, u));
+    }
+
+    public static AccountUpdater joinConsumers(
+        List<Consumer<InternalAccountUpdate.Builder>> consumers) {
+      return join(Lists.transform(consumers, AccountUpdater::fromConsumer));
+    }
+
+    static AccountUpdater fromConsumer(Consumer<InternalAccountUpdate.Builder> consumer) {
+      return (a, u) -> consumer.accept(u);
+    }
+  }
+
+  /**
    * Factory to create an AccountsUpdate instance for updating accounts by the Gerrit server.
    *
    * <p>The Gerrit server identity will be used as author and committer for all commits that update
@@ -75,8 +118,10 @@
     private final GitReferenceUpdated gitRefUpdated;
     private final AllUsersName allUsersName;
     private final OutgoingEmailValidator emailValidator;
-    private final Provider<PersonIdent> serverIdent;
-    private final Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
+    private final Provider<PersonIdent> serverIdentProvider;
+    private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+    private final RetryHelper retryHelper;
+    private final ExternalIdNotes.Factory extIdNotesFactory;
 
     @Inject
     public Server(
@@ -84,26 +129,33 @@
         GitReferenceUpdated gitRefUpdated,
         AllUsersName allUsersName,
         OutgoingEmailValidator emailValidator,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory) {
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        ExternalIdNotes.Factory extIdNotesFactory) {
       this.repoManager = repoManager;
       this.gitRefUpdated = gitRefUpdated;
       this.allUsersName = allUsersName;
       this.emailValidator = emailValidator;
-      this.serverIdent = serverIdent;
-      this.metaDataUpdateServerFactory = metaDataUpdateServerFactory;
+      this.serverIdentProvider = serverIdentProvider;
+      this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
+      this.retryHelper = retryHelper;
+      this.extIdNotesFactory = extIdNotesFactory;
     }
 
     public AccountsUpdate create() {
-      PersonIdent i = serverIdent.get();
+      PersonIdent serverIdent = serverIdentProvider.get();
       return new AccountsUpdate(
           repoManager,
           gitRefUpdated,
           null,
           allUsersName,
           emailValidator,
-          i,
-          () -> metaDataUpdateServerFactory.get().create(allUsersName));
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          extIdNotesFactory,
+          serverIdent,
+          serverIdent);
     }
   }
 
@@ -119,9 +171,11 @@
     private final GitReferenceUpdated gitRefUpdated;
     private final AllUsersName allUsersName;
     private final OutgoingEmailValidator emailValidator;
-    private final Provider<PersonIdent> serverIdent;
+    private final Provider<PersonIdent> serverIdentProvider;
     private final Provider<IdentifiedUser> identifiedUser;
-    private final Provider<MetaDataUpdate.User> metaDataUpdateUserFactory;
+    private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+    private final RetryHelper retryHelper;
+    private final ExternalIdNotes.Factory extIdNotesFactory;
 
     @Inject
     public User(
@@ -129,29 +183,37 @@
         GitReferenceUpdated gitRefUpdated,
         AllUsersName allUsersName,
         OutgoingEmailValidator emailValidator,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
         Provider<IdentifiedUser> identifiedUser,
-        Provider<MetaDataUpdate.User> metaDataUpdateUserFactory) {
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        ExternalIdNotes.Factory extIdNotesFactory) {
       this.repoManager = repoManager;
       this.gitRefUpdated = gitRefUpdated;
       this.allUsersName = allUsersName;
-      this.serverIdent = serverIdent;
+      this.serverIdentProvider = serverIdentProvider;
       this.emailValidator = emailValidator;
       this.identifiedUser = identifiedUser;
-      this.metaDataUpdateUserFactory = metaDataUpdateUserFactory;
+      this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
+      this.retryHelper = retryHelper;
+      this.extIdNotesFactory = extIdNotesFactory;
     }
 
     public AccountsUpdate create() {
       IdentifiedUser user = identifiedUser.get();
-      PersonIdent i = serverIdent.get();
+      PersonIdent serverIdent = serverIdentProvider.get();
+      PersonIdent userIdent = createPersonIdent(serverIdent, user);
       return new AccountsUpdate(
           repoManager,
           gitRefUpdated,
           user,
           allUsersName,
           emailValidator,
-          createPersonIdent(i, user),
-          () -> metaDataUpdateUserFactory.get().create(allUsersName));
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          extIdNotesFactory,
+          serverIdent,
+          userIdent);
     }
 
     private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
@@ -164,8 +226,12 @@
   @Nullable private final IdentifiedUser currentUser;
   private final AllUsersName allUsersName;
   private final OutgoingEmailValidator emailValidator;
+  private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+  private final RetryHelper retryHelper;
+  private final ExternalIdNotes.Factory extIdNotesFactory;
   private final PersonIdent committerIdent;
-  private final MetaDataUpdateFactory metaDataUpdateFactory;
+  private final PersonIdent authorIdent;
+  private final Runnable afterReadRevision;
 
   private AccountsUpdate(
       GitRepositoryManager repoManager,
@@ -173,36 +239,99 @@
       @Nullable IdentifiedUser currentUser,
       AllUsersName allUsersName,
       OutgoingEmailValidator emailValidator,
+      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+      RetryHelper retryHelper,
+      ExternalIdNotes.Factory extIdNotesFactory,
       PersonIdent committerIdent,
-      MetaDataUpdateFactory metaDataUpdateFactory) {
+      PersonIdent authorIdent) {
+    this(
+        repoManager,
+        gitRefUpdated,
+        currentUser,
+        allUsersName,
+        emailValidator,
+        metaDataUpdateInternalFactory,
+        retryHelper,
+        extIdNotesFactory,
+        committerIdent,
+        authorIdent,
+        Runnables.doNothing());
+  }
+
+  @VisibleForTesting
+  public AccountsUpdate(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      @Nullable IdentifiedUser currentUser,
+      AllUsersName allUsersName,
+      OutgoingEmailValidator emailValidator,
+      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+      RetryHelper retryHelper,
+      ExternalIdNotes.Factory extIdNotesFactory,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      Runnable afterReadRevision) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
     this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
     this.currentUser = currentUser;
     this.allUsersName = checkNotNull(allUsersName, "allUsersName");
     this.emailValidator = checkNotNull(emailValidator, "emailValidator");
+    this.metaDataUpdateInternalFactory =
+        checkNotNull(metaDataUpdateInternalFactory, "metaDataUpdateInternalFactory");
+    this.retryHelper = checkNotNull(retryHelper, "retryHelper");
+    this.extIdNotesFactory = checkNotNull(extIdNotesFactory, "extIdNotesFactory");
     this.committerIdent = checkNotNull(committerIdent, "committerIdent");
-    this.metaDataUpdateFactory = checkNotNull(metaDataUpdateFactory, "metaDataUpdateFactory");
+    this.authorIdent = checkNotNull(authorIdent, "authorIdent");
+    this.afterReadRevision = afterReadRevision;
   }
 
   /**
    * Inserts a new account.
    *
+   * @param message commit message for the account creation, must not be {@code null or empty}
    * @param accountId ID of the new account
    * @param init consumer to populate the new account
    * @return the newly created account
    * @throws OrmDuplicateKeyException if the account already exists
-   * @throws IOException if updating the user branch fails
+   * @throws IOException if creating the user branch fails due to an IO error
+   * @throws OrmException if creating the user branch fails
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public Account insert(Account.Id accountId, Consumer<Account> init)
-      throws OrmDuplicateKeyException, IOException, ConfigInvalidException {
-    AccountConfig accountConfig = read(accountId);
-    Account account = accountConfig.getNewAccount();
-    init.accept(account);
+  public Account insert(
+      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> init)
+      throws OrmException, IOException, ConfigInvalidException {
+    return insert(message, accountId, AccountUpdater.fromConsumer(init));
+  }
 
-    // Create in NoteDb
-    commitNew(accountConfig);
-    return account;
+  /**
+   * Inserts a new account.
+   *
+   * @param message commit message for the account creation, must not be {@code null or empty}
+   * @param accountId ID of the new account
+   * @param updater updater to populate the new account
+   * @return the newly created account
+   * @throws OrmDuplicateKeyException if the account already exists
+   * @throws IOException if creating the user branch fails due to an IO error
+   * @throws OrmException if creating the user branch fails
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public Account insert(String message, Account.Id accountId, AccountUpdater updater)
+      throws OrmException, IOException, ConfigInvalidException {
+    return updateAccount(
+        r -> {
+          AccountConfig accountConfig = read(r, accountId);
+          Account account =
+              accountConfig.getNewAccount(new Timestamp(committerIdent.getWhen().getTime()));
+          InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
+          updater.update(account, updateBuilder);
+
+          InternalAccountUpdate update = updateBuilder.build();
+          accountConfig.setAccountUpdate(update);
+          ExternalIdNotes extIdNotes = createExternalIdNotes(r, accountId, update);
+          UpdatedAccount updatedAccounts = new UpdatedAccount(message, accountConfig, extIdNotes);
+          updatedAccounts.setCreated(true);
+          return updatedAccounts;
+        });
   }
 
   /**
@@ -210,15 +339,18 @@
    *
    * <p>Changing the registration date of an account is not supported.
    *
+   * @param message commit message for the account update, must not be {@code null or empty}
    * @param accountId ID of the account
-   * @param consumer consumer to update the account, only invoked if the account exists
+   * @param update consumer to update the account, only invoked if the account exists
    * @return the updated account, {@code null} if the account doesn't exist
-   * @throws IOException if updating the user branch fails
+   * @throws IOException if updating the user branch fails due to an IO error
+   * @throws OrmException if updating the user branch fails
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public Account update(Account.Id accountId, Consumer<Account> consumer)
-      throws IOException, ConfigInvalidException {
-    return update(accountId, ImmutableList.of(consumer));
+  public Account update(
+      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> update)
+      throws OrmException, IOException, ConfigInvalidException {
+    return update(message, accountId, AccountUpdater.fromConsumer(update));
   }
 
   /**
@@ -226,33 +358,45 @@
    *
    * <p>Changing the registration date of an account is not supported.
    *
+   * @param message commit message for the account update, must not be {@code null or empty}
    * @param accountId ID of the account
-   * @param consumers consumers to update the account, only invoked if the account exists
+   * @param updater updater to update the account, only invoked if the account exists
    * @return the updated account, {@code null} if the account doesn't exist
-   * @throws IOException if updating the user branch fails
+   * @throws IOException if updating the user branch fails due to an IO error
+   * @throws OrmException if updating the user branch fails
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
   @Nullable
-  public Account update(Account.Id accountId, List<Consumer<Account>> consumers)
-      throws IOException, ConfigInvalidException {
-    AccountConfig accountConfig = read(accountId);
-    Optional<Account> account = accountConfig.getLoadedAccount();
-    if (!account.isPresent()) {
-      return null;
-    }
+  public Account update(String message, Account.Id accountId, AccountUpdater updater)
+      throws OrmException, IOException, ConfigInvalidException {
+    return updateAccount(
+        r -> {
+          AccountConfig accountConfig = read(r, accountId);
+          Optional<Account> account = accountConfig.getLoadedAccount();
+          if (!account.isPresent()) {
+            return null;
+          }
 
-    consumers.stream().forEach(c -> c.accept(account.get()));
-    commit(accountConfig);
-    return account.get();
+          InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
+          updater.update(account.get(), updateBuilder);
+
+          InternalAccountUpdate update = updateBuilder.build();
+          accountConfig.setAccountUpdate(update);
+          ExternalIdNotes extIdNotes = createExternalIdNotes(r, accountId, update);
+          UpdatedAccount updatedAccounts = new UpdatedAccount(message, accountConfig, extIdNotes);
+          return updatedAccounts;
+        });
   }
 
   /**
    * Deletes the account.
    *
    * @param account the account that should be deleted
-   * @throws IOException if updating the user branch fails
+   * @throws IOException if deleting the user branch fails due to an IO error
+   * @throws OrmException if deleting the user branch fails
+   * @throws ConfigInvalidException
    */
-  public void delete(Account account) throws IOException {
+  public void delete(Account account) throws IOException, OrmException, ConfigInvalidException {
     deleteByKey(account.getId());
   }
 
@@ -260,15 +404,27 @@
    * Deletes the account.
    *
    * @param accountId the ID of the account that should be deleted
-   * @throws IOException if updating the user branch fails
+   * @throws IOException if deleting the user branch fails due to an IO error
+   * @throws OrmException if deleting the user branch fails
+   * @throws ConfigInvalidException
    */
-  public void deleteByKey(Account.Id accountId) throws IOException {
-    deleteUserBranch(accountId);
+  public void deleteByKey(Account.Id accountId)
+      throws IOException, OrmException, ConfigInvalidException {
+    deleteAccount(accountId);
+  }
+
+  private Account deleteAccount(Account.Id accountId)
+      throws IOException, OrmException, ConfigInvalidException {
+    return retryHelper.execute(
+        () -> {
+          deleteUserBranch(accountId);
+          return null;
+        });
   }
 
   private void deleteUserBranch(Account.Id accountId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
-      deleteUserBranch(repo, allUsersName, gitRefUpdated, currentUser, committerIdent, accountId);
+      deleteUserBranch(repo, allUsersName, gitRefUpdated, currentUser, authorIdent, accountId);
     }
   }
 
@@ -299,34 +455,177 @@
     gitRefUpdated.fire(project, ru, user != null ? user.getAccount() : null);
   }
 
-  private AccountConfig read(Account.Id accountId) throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      AccountConfig accountConfig = new AccountConfig(emailValidator, accountId);
-      accountConfig.load(repo);
-      return accountConfig;
-    }
+  private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    AccountConfig accountConfig = new AccountConfig(emailValidator, accountId);
+    accountConfig.load(allUsersRepo);
+
+    afterReadRevision.run();
+
+    return accountConfig;
   }
 
-  private void commitNew(AccountConfig accountConfig) throws IOException {
+  private Account updateAccount(AccountUpdate accountUpdate)
+      throws IOException, ConfigInvalidException, OrmException {
+    return retryHelper.execute(
+        () -> {
+          try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+            UpdatedAccount updatedAccount = accountUpdate.update(allUsersRepo);
+            if (updatedAccount == null) {
+              return null;
+            }
+
+            commit(allUsersRepo, updatedAccount);
+            return updatedAccount.getAccount();
+          }
+        });
+  }
+
+  private ExternalIdNotes createExternalIdNotes(
+      Repository allUsersRepo, Account.Id accountId, InternalAccountUpdate update)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    ExternalIdNotes.checkSameAccount(
+        Iterables.concat(
+            update.getCreatedExternalIds(),
+            update.getUpdatedExternalIds(),
+            update.getDeletedExternalIds()),
+        accountId);
+
+    ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+    extIdNotes.replace(update.getDeletedExternalIds(), update.getCreatedExternalIds());
+    extIdNotes.upsert(update.getUpdatedExternalIds());
+    return extIdNotes;
+  }
+
+  private void commit(Repository allUsersRepo, UpdatedAccount updatedAccount) throws IOException {
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    if (updatedAccount.isCreated()) {
+      commitNewAccountConfig(
+          updatedAccount.getMessage(),
+          allUsersRepo,
+          batchRefUpdate,
+          updatedAccount.getAccountConfig());
+    } else {
+      commitAccountConfig(
+          updatedAccount.getMessage(),
+          allUsersRepo,
+          batchRefUpdate,
+          updatedAccount.getAccountConfig());
+    }
+
+    commitExternalIdUpdates(
+        updatedAccount.getMessage(),
+        allUsersRepo,
+        batchRefUpdate,
+        updatedAccount.getExternalIdNotes());
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+    updatedAccount.getExternalIdNotes().updateCaches();
+    gitRefUpdated.fire(
+        allUsersName, batchRefUpdate, currentUser != null ? currentUser.getAccount() : null);
+  }
+
+  private void commitNewAccountConfig(
+      String message,
+      Repository allUsersRepo,
+      BatchRefUpdate batchRefUpdate,
+      AccountConfig accountConfig)
+      throws IOException {
     // When creating a new account we must allow empty commits so that the user branch gets created
     // with an empty commit when no account properties are set and hence no 'account.config' file
     // will be created.
-    commit(accountConfig, true);
+    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, true);
   }
 
-  private void commit(AccountConfig accountConfig) throws IOException {
-    commit(accountConfig, false);
+  private void commitAccountConfig(
+      String message,
+      Repository allUsersRepo,
+      BatchRefUpdate batchRefUpdate,
+      AccountConfig accountConfig)
+      throws IOException {
+    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, false);
   }
 
-  private void commit(AccountConfig accountConfig, boolean allowEmptyCommit) throws IOException {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create()) {
+  private void commitAccountConfig(
+      String message,
+      Repository allUsersRepo,
+      BatchRefUpdate batchRefUpdate,
+      AccountConfig accountConfig,
+      boolean allowEmptyCommit)
+      throws IOException {
+    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
       md.setAllowEmpty(allowEmptyCommit);
       accountConfig.commit(md);
     }
   }
 
+  private void commitExternalIdUpdates(
+      String message,
+      Repository allUsersRepo,
+      BatchRefUpdate batchRefUpdate,
+      ExternalIdNotes extIdNotes)
+      throws IOException {
+    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
+      extIdNotes.commit(md);
+    }
+  }
+
+  private MetaDataUpdate createMetaDataUpdate(
+      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) {
+    MetaDataUpdate metaDataUpdate =
+        metaDataUpdateInternalFactory.get().create(allUsersName, allUsersRepo, batchRefUpdate);
+    if (!message.endsWith("\n")) {
+      message = message + "\n";
+    }
+
+    metaDataUpdate.getCommitBuilder().setMessage(message);
+    metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
+    metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
+    return metaDataUpdate;
+  }
+
   @FunctionalInterface
-  private static interface MetaDataUpdateFactory {
-    MetaDataUpdate create() throws IOException;
+  private static interface AccountUpdate {
+    UpdatedAccount update(Repository allUsersRepo)
+        throws IOException, ConfigInvalidException, OrmException;
+  }
+
+  private static class UpdatedAccount {
+    private final String message;
+    private final AccountConfig accountConfig;
+    private final ExternalIdNotes extIdNotes;
+
+    private boolean created;
+
+    private UpdatedAccount(
+        String message, AccountConfig accountConfig, ExternalIdNotes extIdNotes) {
+      checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
+      this.message = checkNotNull(message);
+      this.accountConfig = checkNotNull(accountConfig);
+      this.extIdNotes = checkNotNull(extIdNotes);
+    }
+
+    public String getMessage() {
+      return message;
+    }
+
+    public AccountConfig getAccountConfig() {
+      return accountConfig;
+    }
+
+    public Account getAccount() {
+      return accountConfig.getLoadedAccount().get();
+    }
+
+    public ExternalIdNotes getExternalIdNotes() {
+      return extIdNotes;
+    }
+
+    public void setCreated(boolean created) {
+      this.created = created;
+    }
+
+    public boolean isCreated() {
+      return created;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/account/ChangeUserName.java b/java/com/google/gerrit/server/account/ChangeUserName.java
index 3d1a5f6..b332b75 100644
--- a/java/com/google/gerrit/server/account/ChangeUserName.java
+++ b/java/com/google/gerrit/server/account/ChangeUserName.java
@@ -22,7 +22,6 @@
 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;
@@ -30,7 +29,6 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.concurrent.Callable;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -43,13 +41,17 @@
 
   /** Generic factory to change any user's username. */
   public interface Factory {
-    ChangeUserName create(IdentifiedUser user, String newUsername);
+    ChangeUserName create(
+        @Assisted("message") String message,
+        IdentifiedUser user,
+        @Assisted("newUsername") String newUsername);
   }
 
   private final SshKeyCache sshKeyCache;
   private final ExternalIds externalIds;
-  private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
+  private final AccountsUpdate.Server accountsUpdate;
 
+  private final String message;
   private final IdentifiedUser user;
   private final String newUsername;
 
@@ -57,12 +59,14 @@
   ChangeUserName(
       SshKeyCache sshKeyCache,
       ExternalIds externalIds,
-      ExternalIdsUpdate.Server externalIdsUpdateFactory,
+      AccountsUpdate.Server accountsUpdate,
+      @Assisted("message") String message,
       @Assisted IdentifiedUser user,
-      @Nullable @Assisted String newUsername) {
+      @Nullable @Assisted("newUsername") String newUsername) {
     this.sshKeyCache = sshKeyCache;
     this.externalIds = externalIds;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+    this.accountsUpdate = accountsUpdate;
+    this.message = message;
     this.user = user;
     this.newUsername = newUsername;
   }
@@ -71,12 +75,10 @@
   public VoidResult call()
       throws OrmException, NameAlreadyUsedException, InvalidUserNameException, IOException,
           ConfigInvalidException {
-    Collection<ExternalId> old = externalIds.byAccount(user.getAccountId(), SCHEME_USERNAME);
-    if (!old.isEmpty()) {
+    if (!externalIds.byAccount(user.getAccountId(), SCHEME_USERNAME).isEmpty()) {
       throw new IllegalStateException(USERNAME_CANNOT_BE_CHANGED);
     }
 
-    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
     if (newUsername != null && !newUsername.isEmpty()) {
       if (!USER_NAME_PATTERN.matcher(newUsername).matches()) {
         throw new InvalidUserNameException();
@@ -84,13 +86,12 @@
 
       ExternalId.Key key = ExternalId.Key.create(SCHEME_USERNAME, newUsername);
       try {
-        String password = null;
-        for (ExternalId i : old) {
-          if (i.password() != null) {
-            password = i.password();
-          }
-        }
-        externalIdsUpdate.insert(ExternalId.create(key, user.getAccountId(), null, password));
+        accountsUpdate
+            .create()
+            .update(
+                message,
+                user.getAccountId(),
+                u -> u.addExternalId(ExternalId.create(key, user.getAccountId(), null, null)));
       } catch (OrmDuplicateKeyException dupeErr) {
         // If we are using this identity, don't report the exception.
         //
@@ -105,13 +106,6 @@
       }
     }
 
-    // If we have any older user names, remove them.
-    //
-    externalIdsUpdate.delete(old);
-    for (ExternalId extId : old) {
-      sshKeyCache.evict(extId.key().id());
-    }
-
     sshKeyCache.evict(newUsername);
     return VoidResult.INSTANCE;
   }
diff --git a/java/com/google/gerrit/server/account/CreateAccount.java b/java/com/google/gerrit/server/account/CreateAccount.java
index ed92a7e..2ce13ea 100644
--- a/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/account/CreateAccount.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -37,16 +38,14 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 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.group.GroupsCollection;
 import com.google.gerrit.server.group.UserInitiated;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.ssh.SshKeyCache;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -72,8 +71,6 @@
   private final AccountsUpdate.User accountsUpdate;
   private final AccountLoader.Factory infoLoader;
   private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
-  private final ExternalIds externalIds;
-  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
   private final Provider<GroupsUpdate> groupsUpdate;
   private final OutgoingEmailValidator validator;
   private final String username;
@@ -88,8 +85,6 @@
       AccountsUpdate.User accountsUpdate,
       AccountLoader.Factory infoLoader,
       DynamicSet<AccountExternalIdCreator> externalIdCreators,
-      ExternalIds externalIds,
-      ExternalIdsUpdate.User externalIdsUpdateFactory,
       @UserInitiated Provider<GroupsUpdate> groupsUpdate,
       OutgoingEmailValidator validator,
       @Assisted String username) {
@@ -101,8 +96,6 @@
     this.accountsUpdate = accountsUpdate;
     this.infoLoader = infoLoader;
     this.externalIdCreators = externalIdCreators;
-    this.externalIds = externalIds;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
     this.groupsUpdate = groupsUpdate;
     this.validator = validator;
     this.username = username;
@@ -130,55 +123,40 @@
     Set<AccountGroup.UUID> groups = parseGroups(input.groups);
 
     Account.Id id = new Account.Id(seq.nextAccountId());
+    List<ExternalId> extIds = new ArrayList<>();
 
-    ExternalId extUser = ExternalId.createUsername(username, id, input.httpPassword);
-    if (externalIds.get(extUser.key()) != null) {
-      throw new ResourceConflictException("username '" + username + "' already exists");
-    }
     if (input.email != null) {
-      if (externalIds.get(ExternalId.Key.create(SCHEME_MAILTO, input.email)) != null) {
-        throw new UnprocessableEntityException("email '" + input.email + "' already exists");
-      }
       if (!validator.isValid(input.email)) {
         throw new BadRequestException("invalid email address");
       }
+      extIds.add(ExternalId.createEmail(id, input.email));
     }
 
-    List<ExternalId> extIds = new ArrayList<>();
-    extIds.add(extUser);
+    extIds.add(ExternalId.createUsername(username, id, input.httpPassword));
     for (AccountExternalIdCreator c : externalIdCreators) {
       extIds.addAll(c.create(id, username, input.email));
     }
 
-    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
     try {
-      externalIdsUpdate.insert(extIds);
-    } catch (OrmDuplicateKeyException duplicateKey) {
-      throw new ResourceConflictException("username '" + username + "' already exists");
-    }
-
-    if (input.email != null) {
-      try {
-        externalIdsUpdate.insert(ExternalId.createEmail(id, input.email));
-      } catch (OrmDuplicateKeyException duplicateKey) {
-        try {
-          externalIdsUpdate.delete(extUser);
-        } catch (IOException | ConfigInvalidException cleanupError) {
-          // Ignored
-        }
-        throw new UnprocessableEntityException("email '" + input.email + "' already exists");
+      accountsUpdate
+          .create()
+          .insert(
+              "Create Account via API",
+              id,
+              u -> u.setFullName(input.name).setPreferredEmail(input.email).addExternalIds(extIds));
+    } catch (DuplicateExternalIdKeyException e) {
+      if (e.getDuplicateKey().isScheme(SCHEME_USERNAME)) {
+        throw new ResourceConflictException(
+            "username '" + e.getDuplicateKey().id() + "' already exists");
+      } else if (e.getDuplicateKey().isScheme(SCHEME_MAILTO)) {
+        throw new UnprocessableEntityException(
+            "email '" + e.getDuplicateKey().id() + "' already exists");
+      } else {
+        // AccountExternalIdCreator returned an external ID that already exists
+        throw e;
       }
     }
 
-    accountsUpdate
-        .create()
-        .insert(
-            id,
-            a -> {
-              a.setFullName(input.name);
-              a.setPreferredEmail(input.email);
-            });
-
     for (AccountGroup.UUID groupUuid : groups) {
       try {
         addGroupMember(groupUuid, id);
diff --git a/java/com/google/gerrit/server/account/InternalAccountUpdate.java b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
new file mode 100644
index 0000000..ea778ca
--- /dev/null
+++ b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
@@ -0,0 +1,390 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the Licens
+
+package com.google.gerrit.server.account;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import java.util.Collection;
+import java.util.Optional;
+
+/**
+ * Class to prepare updates to an account.
+ *
+ * <p>The getters in this class and the setters in the {@link Builder} correspond to fields in
+ * {@link Account}. The account ID and the registration date cannot be updated.
+ */
+@AutoValue
+public abstract class InternalAccountUpdate {
+  public static Builder builder() {
+    return new Builder.WrapperThatConvertsNullStringArgsToEmptyStrings(
+        new AutoValue_InternalAccountUpdate.Builder());
+  }
+
+  /**
+   * Returns the new value for the full name.
+   *
+   * @return the new value for the full name, {@code Optional#empty()} if the full name is not being
+   *     updated, {@code Optional#of("")} if the full name is unset, the wrapped value is never
+   *     {@code null}
+   */
+  public abstract Optional<String> getFullName();
+
+  /**
+   * Returns the new value for the preferred email.
+   *
+   * @return the new value for the preferred email, {@code Optional#empty()} if the preferred email
+   *     is not being updated, {@code Optional#of("")} if the preferred email is unset, the wrapped
+   *     value is never {@code null}
+   */
+  public abstract Optional<String> getPreferredEmail();
+
+  /**
+   * Returns the new value for the active flag.
+   *
+   * @return the new value for the active flag, {@code Optional#empty()} if the active flag is not
+   *     being updated, the wrapped value is never {@code null}
+   */
+  public abstract Optional<Boolean> getActive();
+
+  /**
+   * Returns the new value for the status.
+   *
+   * @return the new value for the status, {@code Optional#empty()} if the status is not being
+   *     updated, {@code Optional#of("")} if the status is unset, the wrapped value is never {@code
+   *     null}
+   */
+  public abstract Optional<String> getStatus();
+
+  /**
+   * Returns external IDs that should be newly created for the account.
+   *
+   * @return external IDs that should be newly created for the account
+   */
+  public abstract ImmutableSet<ExternalId> getCreatedExternalIds();
+
+  /**
+   * Returns external IDs that should be updated for the account.
+   *
+   * @return external IDs that should be updated for the account
+   */
+  public abstract ImmutableSet<ExternalId> getUpdatedExternalIds();
+
+  /**
+   * Returns external IDs that should be deleted for the account.
+   *
+   * @return external IDs that should be deleted for the account
+   */
+  public abstract ImmutableSet<ExternalId> getDeletedExternalIds();
+
+  /**
+   * Class to build an account update.
+   *
+   * <p>Account data is only updated if the corresponding setter is invoked. If a setter is not
+   * invoked the corresponding data stays unchanged. To unset string values the setter can be
+   * invoked with either {@code null} or an empty string ({@code null} is converted to an empty
+   * string by using the {@link WrapperThatConvertsNullStringArgsToEmptyStrings} wrapper, see {@link
+   * InternalAccountUpdate#builder()}).
+   */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    /**
+     * Sets a new full name for the account.
+     *
+     * @param fullName the new full name, if {@code null} or empty string the full name is unset
+     * @return the builder
+     */
+    public abstract Builder setFullName(String fullName);
+
+    /**
+     * Sets a new preferred email for the account.
+     *
+     * @param preferredEmail the new preferred email, if {@code null} or empty string the preferred
+     *     email is unset
+     * @return the builder
+     */
+    public abstract Builder setPreferredEmail(String preferredEmail);
+
+    /**
+     * Sets the active flag for the account.
+     *
+     * @param active {@code true} if the account should be set to active, {@code false} if the
+     *     account should be set to inactive
+     * @return the builder
+     */
+    public abstract Builder setActive(boolean active);
+
+    /**
+     * Sets a new status for the account.
+     *
+     * @param status the new status, if {@code null} or empty string the status is unset
+     * @return the builder
+     */
+    public abstract Builder setStatus(String status);
+
+    /**
+     * Returns a builder for the set of created external IDs.
+     *
+     * @return builder for the set of created external IDs.
+     */
+    abstract ImmutableSet.Builder<ExternalId> createdExternalIdsBuilder();
+
+    /**
+     * Adds a new external ID for the account.
+     *
+     * <p>The account ID of the external ID must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If an external ID with the same ID already exists the account update will fail with {@link
+     * DuplicateExternalIdKeyException}.
+     *
+     * @param extId external ID that should be added
+     * @return the builder
+     */
+    public Builder addExternalId(ExternalId extId) {
+      return addExternalIds(ImmutableSet.of(extId));
+    }
+
+    /**
+     * Adds new external IDs for the account.
+     *
+     * <p>The account IDs of the external IDs must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If any of the external ID keys already exists, the insert fails with {@link
+     * DuplicateExternalIdKeyException}.
+     *
+     * @param extIds external IDs that should be added
+     * @return the builder
+     */
+    public Builder addExternalIds(Collection<ExternalId> extIds) {
+      createdExternalIdsBuilder().addAll(extIds);
+      return this;
+    }
+
+    /**
+     * Returns a builder for the set of updated external IDs.
+     *
+     * @return builder for the set of updated external IDs.
+     */
+    abstract ImmutableSet.Builder<ExternalId> updatedExternalIdsBuilder();
+
+    /**
+     * Updates an external ID for the account.
+     *
+     * <p>The account ID of the external ID must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If no external ID with the ID exists the external ID is created.
+     *
+     * @param extId external ID that should be updated
+     * @return the builder
+     */
+    public Builder updateExternalId(ExternalId extId) {
+      return updateExternalIds(ImmutableSet.of(extId));
+    }
+
+    /**
+     * Updates external IDs for the account.
+     *
+     * <p>The account IDs of the external IDs must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If any of the external IDs already exists, it is overwritten. New external IDs are
+     * inserted.
+     *
+     * @param extIds external IDs that should be updated
+     * @return the builder
+     */
+    public Builder updateExternalIds(Collection<ExternalId> extIds) {
+      updatedExternalIdsBuilder().addAll(extIds);
+      return this;
+    }
+
+    /**
+     * Returns a builder for the set of deleted external IDs.
+     *
+     * @return builder for the set of deleted external IDs.
+     */
+    abstract ImmutableSet.Builder<ExternalId> deletedExternalIdsBuilder();
+
+    /**
+     * Deletes an external ID for the account.
+     *
+     * <p>The account ID of the external ID must match the account ID of the account that is
+     * updated.
+     *
+     * <p>If no external ID with the ID exists this is a no-op.
+     *
+     * @param extId external ID that should be deleted
+     * @return the builder
+     */
+    public Builder deleteExternalId(ExternalId extId) {
+      return deleteExternalIds(ImmutableSet.of(extId));
+    }
+
+    /**
+     * Delete external IDs for the account.
+     *
+     * <p>The account IDs of the external IDs must match the account ID of the account that is
+     * updated.
+     *
+     * <p>For non-existing external IDs this is a no-op.
+     *
+     * @param extIds external IDs that should be deleted
+     * @return the builder
+     */
+    public Builder deleteExternalIds(Collection<ExternalId> extIds) {
+      deletedExternalIdsBuilder().addAll(extIds);
+      return this;
+    }
+
+    /**
+     * Replaces an external ID.
+     *
+     * @param extIdToDelete external ID that should be deleted
+     * @param extIdToAdd external ID that should be added
+     * @return the builder
+     */
+    public Builder replaceExternalId(ExternalId extIdToDelete, ExternalId extIdToAdd) {
+      return replaceExternalIds(ImmutableSet.of(extIdToDelete), ImmutableSet.of(extIdToAdd));
+    }
+
+    /**
+     * Replaces an external IDs.
+     *
+     * @param extIdsToDelete external IDs that should be deleted
+     * @param extIdsToAdd external IDs that should be added
+     * @return the builder
+     */
+    public Builder replaceExternalIds(
+        Collection<ExternalId> extIdsToDelete, Collection<ExternalId> extIdsToAdd) {
+      return deleteExternalIds(extIdsToDelete).addExternalIds(extIdsToAdd);
+    }
+
+    /**
+     * Builds the account update.
+     *
+     * @return the account update
+     */
+    public abstract InternalAccountUpdate build();
+
+    /**
+     * Wrapper for {@link Builder} that converts {@code null} string arguments to empty strings for
+     * all setter methods. This allows us to treat setter invocations with a {@code null} string
+     * argument as signal to unset the corresponding field. E.g. for a builder method {@code
+     * setX(String)} the following semantics apply:
+     *
+     * <ul>
+     *   <li>Method is not invoked: X stays unchanged, X is stored as {@code Optional.empty()}.
+     *   <li>Argument is a non-empty string Y: X is updated to the Y, X is stored as {@code
+     *       Optional.of(Y)}.
+     *   <li>Argument is an empty string: X is unset, X is stored as {@code Optional.of("")}
+     *   <li>Argument is {@code null}: X is unset, X is stored as {@code Optional.of("")} (since the
+     *       wrapper converts {@code null} to an empty string)
+     * </ul>
+     *
+     * Without the wrapper calling {@code setX(null)} would fail with a {@link
+     * NullPointerException}. Hence all callers would need to take care to call {@link
+     * Strings#nullToEmpty(String)} for all string arguments and likely it would be forgotten in
+     * some places.
+     *
+     * <p>This means the stored values are interpreted like this:
+     *
+     * <ul>
+     *   <li>{@code Optional.empty()}: property stays unchanged
+     *   <li>{@code Optional.of(<non-empty-string>)}: property is updated
+     *   <li>{@code Optional.of("")}: property is unset
+     * </ul>
+     *
+     * This wrapper forwards all method invocations to the wrapped {@link Builder} instance that was
+     * created by AutoValue. For methods that return the AutoValue {@link Builder} instance the
+     * return value is replaced with the wrapper instance so that all chained calls go through the
+     * wrapper.
+     */
+    private static class WrapperThatConvertsNullStringArgsToEmptyStrings extends Builder {
+      private final Builder delegate;
+
+      private WrapperThatConvertsNullStringArgsToEmptyStrings(Builder delegate) {
+        this.delegate = delegate;
+      }
+
+      @Override
+      public Builder setFullName(String fullName) {
+        delegate.setFullName(Strings.nullToEmpty(fullName));
+        return this;
+      }
+
+      @Override
+      public Builder setPreferredEmail(String preferredEmail) {
+        delegate.setPreferredEmail(Strings.nullToEmpty(preferredEmail));
+        return this;
+      }
+
+      @Override
+      public Builder setActive(boolean active) {
+        delegate.setActive(active);
+        return this;
+      }
+
+      @Override
+      public Builder setStatus(String status) {
+        delegate.setStatus(Strings.nullToEmpty(status));
+        return this;
+      }
+
+      @Override
+      public InternalAccountUpdate build() {
+        return delegate.build();
+      }
+
+      @Override
+      ImmutableSet.Builder<ExternalId> createdExternalIdsBuilder() {
+        return delegate.createdExternalIdsBuilder();
+      }
+
+      @Override
+      public Builder addExternalIds(Collection<ExternalId> extIds) {
+        delegate.addExternalIds(extIds);
+        return this;
+      }
+
+      @Override
+      ImmutableSet.Builder<ExternalId> updatedExternalIdsBuilder() {
+        return delegate.updatedExternalIdsBuilder();
+      }
+
+      @Override
+      public Builder updateExternalIds(Collection<ExternalId> extIds) {
+        delegate.updateExternalIds(extIds);
+        return this;
+      }
+
+      @Override
+      ImmutableSet.Builder<ExternalId> deletedExternalIdsBuilder() {
+        return delegate.deletedExternalIdsBuilder();
+      }
+
+      @Override
+      public Builder deleteExternalIds(Collection<ExternalId> extIds) {
+        delegate.deleteExternalIds(extIds);
+        return this;
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/PutName.java b/java/com/google/gerrit/server/account/PutName.java
index 0ac9d1d..b63c548 100644
--- a/java/com/google/gerrit/server/account/PutName.java
+++ b/java/com/google/gerrit/server/account/PutName.java
@@ -66,7 +66,7 @@
 
   public Response<String> apply(IdentifiedUser user, NameInput input)
       throws MethodNotAllowedException, ResourceNotFoundException, IOException,
-          ConfigInvalidException {
+          ConfigInvalidException, OrmException {
     if (input == null) {
       input = new NameInput();
     }
@@ -77,7 +77,9 @@
 
     String newName = input.name;
     Account account =
-        accountsUpdate.create().update(user.getAccountId(), a -> a.setFullName(newName));
+        accountsUpdate
+            .create()
+            .update("Set Full Name via API", user.getAccountId(), u -> u.setFullName(newName));
     if (account == null) {
       throw new ResourceNotFoundException("account not found");
     }
diff --git a/java/com/google/gerrit/server/account/PutPreferred.java b/java/com/google/gerrit/server/account/PutPreferred.java
index 5f9ddee..40e2f7a 100644
--- a/java/com/google/gerrit/server/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/account/PutPreferred.java
@@ -61,18 +61,19 @@
   }
 
   public Response<String> apply(IdentifiedUser user, String email)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException, OrmException {
     AtomicBoolean alreadyPreferred = new AtomicBoolean(false);
     Account account =
         accountsUpdate
             .create()
             .update(
+                "Set Preferred Email via API",
                 user.getAccountId(),
-                a -> {
+                (a, u) -> {
                   if (email.equals(a.getPreferredEmail())) {
                     alreadyPreferred.set(true);
                   } else {
-                    a.setPreferredEmail(email);
+                    u.setPreferredEmail(email);
                   }
                 });
     if (account == null) {
diff --git a/java/com/google/gerrit/server/account/PutStatus.java b/java/com/google/gerrit/server/account/PutStatus.java
index 35ece15..85db1da 100644
--- a/java/com/google/gerrit/server/account/PutStatus.java
+++ b/java/com/google/gerrit/server/account/PutStatus.java
@@ -60,7 +60,7 @@
   }
 
   public Response<String> apply(IdentifiedUser user, StatusInput input)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException, OrmException {
     if (input == null) {
       input = new StatusInput();
     }
@@ -69,7 +69,7 @@
     Account account =
         accountsUpdate
             .create()
-            .update(user.getAccountId(), a -> a.setStatus(Strings.nullToEmpty(newStatus)));
+            .update("Set Status via API", user.getAccountId(), u -> u.setStatus(newStatus));
     if (account == null) {
       throw new ResourceNotFoundException("account not found");
     }
diff --git a/java/com/google/gerrit/server/account/PutUsername.java b/java/com/google/gerrit/server/account/PutUsername.java
index 2368913..f4ff79d 100644
--- a/java/com/google/gerrit/server/account/PutUsername.java
+++ b/java/com/google/gerrit/server/account/PutUsername.java
@@ -70,7 +70,7 @@
     }
 
     try {
-      changeUserNameFactory.create(rsrc.getUser(), input.username).call();
+      changeUserNameFactory.create("Set Username via API", rsrc.getUser(), input.username).call();
     } catch (IllegalStateException e) {
       if (ChangeUserName.USERNAME_CANNOT_BE_CHANGED.equals(e.getMessage())) {
         throw new MethodNotAllowedException(e.getMessage());
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index 6e12c3e..f8cd650 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -36,18 +37,19 @@
   }
 
   public Response<?> deactivate(Account.Id accountId)
-      throws RestApiException, IOException, ConfigInvalidException {
+      throws RestApiException, IOException, ConfigInvalidException, OrmException {
     AtomicBoolean alreadyInactive = new AtomicBoolean(false);
     Account account =
         accountsUpdate
             .create()
             .update(
+                "Deactivate Account via API",
                 accountId,
-                a -> {
+                (a, u) -> {
                   if (!a.isActive()) {
                     alreadyInactive.set(true);
                   } else {
-                    a.setActive(false);
+                    u.setActive(false);
                   }
                 });
     if (account == null) {
@@ -60,18 +62,19 @@
   }
 
   public Response<String> activate(Account.Id accountId)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException, OrmException {
     AtomicBoolean alreadyActive = new AtomicBoolean(false);
     Account account =
         accountsUpdate
             .create()
             .update(
+                "Activate Account via API",
                 accountId,
-                a -> {
+                (a, u) -> {
                   if (a.isActive()) {
                     alreadyActive.set(true);
                   } else {
-                    a.setActive(true);
+                    u.setActive(true);
                   }
                 });
     if (account == null) {
diff --git a/java/com/google/gerrit/server/account/StarredChanges.java b/java/com/google/gerrit/server/account/StarredChanges.java
index 38c95e6..6dfd132 100644
--- a/java/com/google/gerrit/server/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/account/StarredChanges.java
@@ -72,7 +72,7 @@
 
   @Override
   public AccountResource.StarredChange parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
+      throws RestApiException, OrmException, PermissionBackendException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     if (starredChangesUtil
@@ -103,7 +103,7 @@
 
   @Override
   public RestModifyView<AccountResource, EmptyInput> create(AccountResource parent, IdString id)
-      throws UnprocessableEntityException {
+      throws RestApiException {
     try {
       return createProvider.get().setChange(changes.parse(TopLevelResource.INSTANCE, id));
     } catch (ResourceNotFoundException e) {
diff --git a/java/com/google/gerrit/server/account/Stars.java b/java/com/google/gerrit/server/account/Stars.java
index 9019ad7..5eb8d7b 100644
--- a/java/com/google/gerrit/server/account/Stars.java
+++ b/java/com/google/gerrit/server/account/Stars.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -66,7 +66,7 @@
 
   @Override
   public Star parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
+      throws RestApiException, OrmException, PermissionBackendException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
diff --git a/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java b/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java
new file mode 100644
index 0000000..b4c82d0
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+
+/**
+ * Exception that is thrown if an external ID cannot be inserted because an external ID with the
+ * same key already exists.
+ */
+public class DuplicateExternalIdKeyException extends OrmDuplicateKeyException {
+  private static final long serialVersionUID = 1L;
+
+  private final ExternalId.Key duplicateKey;
+
+  public DuplicateExternalIdKeyException(ExternalId.Key duplicateKey) {
+    super("Duplicate external ID key: " + duplicateKey.get());
+    this.duplicateKey = duplicateKey;
+  }
+
+  public ExternalId.Key getDuplicateKey() {
+    return duplicateKey;
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index ad119ca..1ac8f3d 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -16,11 +16,13 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
@@ -28,6 +30,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.HashedPassword;
 import java.io.Serializable;
+import java.util.Collection;
 import java.util.Objects;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -123,6 +126,10 @@
     public String toString() {
       return get();
     }
+
+    public static ImmutableSet<ExternalId.Key> from(Collection<ExternalId> extIds) {
+      return extIds.stream().map(ExternalId::key).collect(toImmutableSet());
+    }
   }
 
   public static ExternalId create(String scheme, String id, Account.Id accountId) {
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index 311e70f..6f80713 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -127,7 +127,7 @@
       Collection<ExternalId> toRemove,
       Collection<ExternalId> toAdd)
       throws IOException {
-    ExternalIdsUpdate.checkSameAccount(Iterables.concat(toRemove, toAdd), accountId);
+    ExternalIdNotes.checkSameAccount(Iterables.concat(toRemove, toAdd), accountId);
 
     updateCache(
         oldNotesRev,
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
new file mode 100644
index 0000000..d8148c6
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -0,0 +1,754 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * {@link VersionedMetaData} subclass to update external IDs.
+ *
+ * <p>On load the note map from {@code refs/meta/external-ids} is read, but the external IDs are not
+ * parsed yet (see {@link #onLoad()}).
+ *
+ * <p>After loading the note map callers can access single or all external IDs. Only now the
+ * requested external IDs are parsed.
+ *
+ * <p>After loading the note map callers can stage various external ID updates (insert, upsert,
+ * delete, replace).
+ *
+ * <p>On save the staged external ID updates are performed (see {@link #onSave(CommitBuilder)}).
+ *
+ * <p>After committing the external IDs a cache update can be requested which also reindexes the
+ * accounts for which external IDs have been updated (see {@link #updateCaches()}).
+ */
+public class ExternalIdNotes extends VersionedMetaData {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdNotes.class);
+
+  private static final int MAX_NOTE_SZ = 1 << 19;
+
+  @Singleton
+  public static class Factory {
+    private final ExternalIdCache externalIdCache;
+    private final AccountCache accountCache;
+
+    @Inject
+    Factory(ExternalIdCache externalIdCache, AccountCache accountCache) {
+      this.externalIdCache = externalIdCache;
+      this.accountCache = accountCache;
+    }
+
+    public ExternalIdNotes load(Repository allUsersRepo)
+        throws IOException, ConfigInvalidException {
+      return new ExternalIdNotes(externalIdCache, accountCache, allUsersRepo).load();
+    }
+  }
+
+  @Singleton
+  public static class FactoryNoReindex {
+    private final ExternalIdCache externalIdCache;
+
+    @Inject
+    FactoryNoReindex(ExternalIdCache externalIdCache) {
+      this.externalIdCache = externalIdCache;
+    }
+
+    public ExternalIdNotes load(Repository allUsersRepo)
+        throws IOException, ConfigInvalidException {
+      return new ExternalIdNotes(externalIdCache, null, allUsersRepo).load();
+    }
+  }
+
+  public static ExternalIdNotes loadReadOnly(Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    return new ExternalIdNotes(new DisabledExternalIdCache(), null, allUsersRepo)
+        .setReadOnly()
+        .load();
+  }
+
+  public static ExternalIdNotes loadReadOnly(Repository allUsersRepo, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    return new ExternalIdNotes(new DisabledExternalIdCache(), null, allUsersRepo)
+        .setReadOnly()
+        .load(rev);
+  }
+
+  public static ExternalIdNotes loadNoCacheUpdate(Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    return new ExternalIdNotes(new DisabledExternalIdCache(), null, allUsersRepo).load();
+  }
+
+  private final ExternalIdCache externalIdCache;
+  @Nullable private final AccountCache accountCache;
+  private final Repository repo;
+
+  private NoteMap noteMap;
+  private ObjectId oldRev;
+
+  // Staged note map updates that should be executed on save.
+  private List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
+
+  // Staged cache updates that should be executed after external ID changes have been committed.
+  private List<CacheUpdate> cacheUpdates = new ArrayList<>();
+
+  private Runnable afterReadRevision;
+  private boolean readOnly = false;
+
+  ExternalIdNotes(
+      ExternalIdCache externalIdCache,
+      @Nullable AccountCache accountCache,
+      Repository allUsersRepo) {
+    this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
+    this.accountCache = accountCache;
+    this.repo = checkNotNull(allUsersRepo, "allUsersRepo");
+  }
+
+  public ExternalIdNotes setAfterReadRevision(Runnable afterReadRevision) {
+    this.afterReadRevision = afterReadRevision;
+    return this;
+  }
+
+  private ExternalIdNotes setReadOnly() {
+    this.readOnly = true;
+    return this;
+  }
+
+  public Repository getRepository() {
+    return repo;
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.REFS_EXTERNAL_IDS;
+  }
+
+  ExternalIdNotes load() throws IOException, ConfigInvalidException {
+    load(repo);
+    return this;
+  }
+
+  ExternalIdNotes load(ObjectId rev) throws IOException, ConfigInvalidException {
+    load(repo, rev);
+    return this;
+  }
+
+  /**
+   * Parses and returns the specified external ID.
+   *
+   * @param key the key of the external ID
+   * @return the external ID, {@code Optional.empty()} if it doesn't exist
+   */
+  public Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    checkLoaded();
+    ObjectId noteId = key.sha1();
+    if (!noteMap.contains(noteId)) {
+      return Optional.empty();
+    }
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      ObjectId noteDataId = noteMap.get(noteId);
+      byte[] raw = readNoteData(rw, noteDataId);
+      return Optional.of(ExternalId.parse(noteId.name(), raw, noteDataId));
+    }
+  }
+
+  /**
+   * Parses and returns the specified external IDs.
+   *
+   * @param keys the keys of the external IDs
+   * @return the external IDs
+   */
+  public Set<ExternalId> get(Collection<ExternalId.Key> keys)
+      throws IOException, ConfigInvalidException {
+    checkLoaded();
+    HashSet<ExternalId> externalIds = Sets.newHashSetWithExpectedSize(keys.size());
+    for (ExternalId.Key key : keys) {
+      get(key).ifPresent(externalIds::add);
+    }
+    return externalIds;
+  }
+
+  /**
+   * Parses and returns all external IDs.
+   *
+   * <p>Invalid external IDs are ignored.
+   *
+   * @return all external IDs
+   */
+  public Set<ExternalId> all() throws IOException {
+    checkLoaded();
+    try (RevWalk rw = new RevWalk(repo)) {
+      Set<ExternalId> extIds = new HashSet<>();
+      for (Note note : noteMap) {
+        byte[] raw = readNoteData(rw, note.getData());
+        try {
+          extIds.add(ExternalId.parse(note.getName(), raw, note.getData()));
+        } catch (ConfigInvalidException | RuntimeException e) {
+          log.error(String.format("Ignoring invalid external ID note %s", note.getName()), e);
+        }
+      }
+      return extIds;
+    }
+  }
+
+  NoteMap getNoteMap() {
+    checkLoaded();
+    return noteMap;
+  }
+
+  static byte[] readNoteData(RevWalk rw, ObjectId noteDataId) throws IOException {
+    return rw.getObjectReader().open(noteDataId, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+  }
+
+  /**
+   * Inserts a new external ID.
+   *
+   * @throws IOException on IO error while checking if external ID already exists
+   * @throws DuplicateExternalIdKeyException if the external ID already exists
+   */
+  public void insert(ExternalId extId) throws IOException, DuplicateExternalIdKeyException {
+    insert(Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts new external IDs.
+   *
+   * @throws IOException on IO error while checking if external IDs already exist
+   * @throws DuplicateExternalIdKeyException if any of the external ID already exists
+   */
+  public void insert(Collection<ExternalId> extIds)
+      throws IOException, DuplicateExternalIdKeyException {
+    checkLoaded();
+    checkExternalIdsDontExist(extIds);
+
+    Set<ExternalId> newExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId extId : extIds) {
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            newExtIds.add(insertedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.add(newExtIds));
+  }
+
+  /**
+   * Inserts or updates an external ID.
+   *
+   * <p>If the external ID already exists, it is overwritten, otherwise it is inserted.
+   */
+  public void upsert(ExternalId extId) throws IOException, ConfigInvalidException {
+    upsert(Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts or updates external IDs.
+   *
+   * <p>If any of the external IDs already exists, it is overwritten. New external IDs are inserted.
+   */
+  public void upsert(Collection<ExternalId> extIds) throws IOException, ConfigInvalidException {
+    checkLoaded();
+    Set<ExternalId> removedExtIds = get(ExternalId.Key.from(extIds));
+    Set<ExternalId> updatedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId extId : extIds) {
+            ExternalId updatedExtId = upsert(rw, inserter, noteMap, extId);
+            updatedExtIds.add(updatedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.remove(removedExtIds).add(updatedExtIds));
+  }
+
+  /**
+   * Deletes an external ID.
+   *
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
+   */
+  public void delete(ExternalId extId) {
+    delete(Collections.singleton(extId));
+  }
+
+  /**
+   * Deletes external IDs.
+   *
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key as any of the external IDs that should be deleted, but otherwise doesn't match the that
+   *     external ID.
+   */
+  public void delete(Collection<ExternalId> extIds) {
+    checkLoaded();
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId extId : extIds) {
+            remove(rw, noteMap, extId);
+            removedExtIds.add(extId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.remove(removedExtIds));
+  }
+
+  /**
+   * Delete an external ID by key.
+   *
+   * @throws IllegalStateException is thrown if the external ID does not belong to the specified
+   *     account.
+   */
+  public void delete(Account.Id accountId, ExternalId.Key extIdKey) {
+    delete(accountId, Collections.singleton(extIdKey));
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * @throws IllegalStateException is thrown if any of the external IDs does not belong to the
+   *     specified account.
+   */
+  public void delete(Account.Id accountId, Collection<ExternalId.Key> extIdKeys) {
+    checkLoaded();
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId.Key extIdKey : extIdKeys) {
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
+            removedExtIds.add(removedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.remove(removedExtIds));
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * <p>The external IDs are deleted regardless of which account they belong to.
+   */
+  public void deleteByKeys(Collection<ExternalId.Key> extIdKeys) {
+    checkLoaded();
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId.Key extIdKey : extIdKeys) {
+            ExternalId extId = remove(rw, noteMap, extIdKey, null);
+            removedExtIds.add(extId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.remove(removedExtIds));
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * @throws IllegalStateException is thrown if any of the specified external IDs does not belong to
+   *     the specified account.
+   */
+  public void replace(
+      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    checkLoaded();
+    checkSameAccount(toAdd, accountId);
+    checkExternalIdKeysDontExist(ExternalId.Key.from(toAdd), toDelete);
+
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    Set<ExternalId> updatedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId.Key extIdKey : toDelete) {
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
+            if (removedExtId != null) {
+              removedExtIds.add(removedExtId);
+            }
+          }
+
+          for (ExternalId extId : toAdd) {
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            updatedExtIds.add(insertedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * <p>The external IDs are replaced regardless of which account they belong to.
+   */
+  public void replaceByKeys(Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    checkLoaded();
+    checkExternalIdKeysDontExist(ExternalId.Key.from(toAdd), toDelete);
+
+    Set<ExternalId> removedExtIds = new HashSet<>();
+    Set<ExternalId> updatedExtIds = new HashSet<>();
+    noteMapUpdates.add(
+        (rw, n) -> {
+          for (ExternalId.Key extIdKey : toDelete) {
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, null);
+            removedExtIds.add(removedExtId);
+          }
+
+          for (ExternalId extId : toAdd) {
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            updatedExtIds.add(insertedExtId);
+          }
+        });
+    cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+  }
+
+  /**
+   * Replaces an external ID.
+   *
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
+   */
+  public void replace(ExternalId toDelete, ExternalId toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    replace(Collections.singleton(toDelete), Collections.singleton(toAdd));
+  }
+
+  /**
+   * Replaces external IDs.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID is specified for deletion and an external ID with the same key is specified to be
+   * added, the old external ID with that key is deleted first and then the new external ID is added
+   * (so the external ID for that key is replaced).
+   *
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
+   */
+  public void replace(Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
+    if (accountId == null) {
+      // toDelete and toAdd are empty -> nothing to do
+      return;
+    }
+
+    replace(accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    noteMap = revision != null ? NoteMap.read(reader, revision) : NoteMap.newEmptyMap();
+
+    if (afterReadRevision != null) {
+      afterReadRevision.run();
+    }
+  }
+
+  @Override
+  public RevCommit commit(MetaDataUpdate update) throws IOException {
+    oldRev = revision != null ? revision.copy() : ObjectId.zeroId();
+    return super.commit(update);
+  }
+
+  /**
+   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
+   * external IDs were modified.
+   *
+   * <p>Must only be called after committing changes.
+   *
+   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(Repository)}.
+   *
+   * <p>No eviction from account cache if this instance was created by {@link FactoryNoReindex}.
+   */
+  public void updateCaches() throws IOException {
+    checkState(oldRev != null, "no changes committed yet");
+
+    ExternalIdCacheUpdates externalIdCacheUpdates = new ExternalIdCacheUpdates();
+    for (CacheUpdate cacheUpdate : cacheUpdates) {
+      cacheUpdate.execute(externalIdCacheUpdates);
+    }
+
+    externalIdCache.onReplace(
+        oldRev,
+        getRevision(),
+        externalIdCacheUpdates.getRemoved(),
+        externalIdCacheUpdates.getAdded());
+
+    if (accountCache != null) {
+      for (Account.Id id :
+          Streams.concat(
+                  externalIdCacheUpdates.getAdded().stream(),
+                  externalIdCacheUpdates.getRemoved().stream())
+              .map(ExternalId::accountId)
+              .collect(toSet())) {
+        accountCache.evict(id);
+      }
+    }
+
+    cacheUpdates.clear();
+    oldRev = null;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    checkState(!readOnly, "Updating external IDs is disabled");
+
+    if (noteMapUpdates.isEmpty()) {
+      return false;
+    }
+
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage("Update external IDs\n");
+    }
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      for (NoteMapUpdate noteMapUpdate : noteMapUpdates) {
+        try {
+          noteMapUpdate.execute(rw, noteMap);
+        } catch (DuplicateExternalIdKeyException e) {
+          throw new IOException(e);
+        }
+      }
+      noteMapUpdates.clear();
+
+      RevTree oldTree = revision != null ? rw.parseTree(revision) : null;
+      ObjectId newTreeId = noteMap.writeTree(inserter);
+      if (newTreeId.equals(oldTree)) {
+        return false;
+      }
+
+      commit.setTreeId(newTreeId);
+      return true;
+    }
+  }
+
+  /**
+   * Checks that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  private static Account.Id checkSameAccount(Iterable<ExternalId> extIds) {
+    return checkSameAccount(extIds, null);
+  }
+
+  /**
+   * Checks that all specified external IDs belong to specified account. If no account is specified
+   * it is checked that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  public static Account.Id checkSameAccount(
+      Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
+    for (ExternalId extId : extIds) {
+      if (accountId == null) {
+        accountId = extId.accountId();
+        continue;
+      }
+      checkState(
+          accountId.equals(extId.accountId()),
+          "external id %s belongs to account %s, expected account %s",
+          extId.key().get(),
+          extId.accountId().get(),
+          accountId.get());
+    }
+    return accountId;
+  }
+
+  /**
+   * Insert or updates an new external ID and sets it in the note map.
+   *
+   * <p>If the external ID already exists it is overwritten.
+   */
+  private static ExternalId upsert(
+      RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extId.key().sha1();
+    Config c = new Config();
+    if (noteMap.contains(extId.key().sha1())) {
+      byte[] raw = readNoteData(rw, noteMap.get(noteId));
+      try {
+        c = new BlobBasedConfig(null, raw);
+      } catch (ConfigInvalidException e) {
+        throw new ConfigInvalidException(
+            String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
+      }
+    }
+    extId.writeToConfig(c);
+    byte[] raw = c.toText().getBytes(UTF_8);
+    ObjectId noteData = ins.insert(OBJ_BLOB, raw);
+    noteMap.set(noteId, noteData);
+    return ExternalId.create(extId, noteData);
+  }
+
+  /**
+   * Removes an external ID from the note map.
+   *
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
+   */
+  private static ExternalId remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extId.key().sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    ObjectId noteDataId = noteMap.get(noteId);
+    byte[] raw = readNoteData(rw, noteDataId);
+    ExternalId actualExtId = ExternalId.parse(noteId.name(), raw, noteDataId);
+    checkState(
+        extId.equals(actualExtId),
+        "external id %s should be removed, but it's not matching the actual external id %s",
+        extId.toString(),
+        actualExtId.toString());
+    noteMap.remove(noteId);
+    return actualExtId;
+  }
+
+  /**
+   * Removes an external ID from the note map by external ID key.
+   *
+   * @throws IllegalStateException is thrown if an expected account ID is provided and an external
+   *     ID with the specified key exists, but belongs to another account.
+   * @return the external ID that was removed, {@code null} if no external ID with the specified key
+   *     exists
+   */
+  private static ExternalId remove(
+      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extIdKey.sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    ObjectId noteDataId = noteMap.get(noteId);
+    byte[] raw = readNoteData(rw, noteDataId);
+    ExternalId extId = ExternalId.parse(noteId.name(), raw, noteDataId);
+    if (expectedAccountId != null) {
+      checkState(
+          expectedAccountId.equals(extId.accountId()),
+          "external id %s should be removed for account %s,"
+              + " but external id belongs to account %s",
+          extIdKey.get(),
+          expectedAccountId.get(),
+          extId.accountId().get());
+    }
+    noteMap.remove(noteId);
+    return extId;
+  }
+
+  private void checkExternalIdsDontExist(Collection<ExternalId> extIds)
+      throws DuplicateExternalIdKeyException, IOException {
+    checkExternalIdKeysDontExist(ExternalId.Key.from(extIds));
+  }
+
+  private void checkExternalIdKeysDontExist(
+      Collection<ExternalId.Key> extIdKeysToAdd, Collection<ExternalId.Key> extIdKeysToDelete)
+      throws DuplicateExternalIdKeyException, IOException {
+    HashSet<ExternalId.Key> newKeys = new HashSet<>(extIdKeysToAdd);
+    newKeys.removeAll(extIdKeysToDelete);
+    checkExternalIdKeysDontExist(newKeys);
+  }
+
+  private void checkExternalIdKeysDontExist(Collection<ExternalId.Key> extIdKeys)
+      throws IOException, DuplicateExternalIdKeyException {
+    for (ExternalId.Key extIdKey : extIdKeys) {
+      if (noteMap.contains(extIdKey.sha1())) {
+        throw new DuplicateExternalIdKeyException(extIdKey);
+      }
+    }
+  }
+
+  private void checkLoaded() {
+    checkState(noteMap != null, "External IDs not loaded yet");
+  }
+
+  @FunctionalInterface
+  private interface NoteMapUpdate {
+    void execute(RevWalk rw, NoteMap noteMap)
+        throws IOException, ConfigInvalidException, DuplicateExternalIdKeyException;
+  }
+
+  @FunctionalInterface
+  private interface CacheUpdate {
+    void execute(ExternalIdCacheUpdates cacheUpdates) throws IOException;
+  }
+
+  private static class ExternalIdCacheUpdates {
+    private final Set<ExternalId> added = new HashSet<>();
+    private final Set<ExternalId> removed = new HashSet<>();
+
+    ExternalIdCacheUpdates add(Collection<ExternalId> extIds) {
+      this.added.addAll(extIds);
+      return this;
+    }
+
+    public Set<ExternalId> getAdded() {
+      return ImmutableSet.copyOf(added);
+    }
+
+    ExternalIdCacheUpdates remove(Collection<ExternalId> extIds) {
+      this.removed.addAll(extIds);
+      return this;
+    }
+
+    public Set<ExternalId> getRemoved() {
+      return ImmutableSet.copyOf(removed);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index bf78b13..f97757f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -29,17 +26,13 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.HashSet;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * Class to read external IDs from NoteDb.
@@ -58,10 +51,6 @@
  */
 @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();
@@ -104,11 +93,12 @@
   }
 
   /** Reads and returns all external IDs. */
-  Set<ExternalId> all() throws IOException {
+  Set<ExternalId> all() throws IOException, ConfigInvalidException {
     checkReadEnabled();
 
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return all(repo, readRevision(repo));
+    try (Timer0.Context ctx = readAllLatency.start();
+        Repository repo = repoManager.openRepository(allUsersName)) {
+      return ExternalIdNotes.loadReadOnly(repo).all();
     }
   }
 
@@ -116,34 +106,12 @@
    * Reads and returns all external IDs from the specified revision of the refs/meta/external-ids
    * branch.
    */
-  Set<ExternalId> all(ObjectId rev) throws IOException {
+  Set<ExternalId> all(ObjectId rev) throws IOException, ConfigInvalidException {
     checkReadEnabled();
 
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return all(repo, rev);
-    }
-  }
-
-  /** Reads and returns all external IDs. */
-  private Set<ExternalId> all(Repository repo, ObjectId rev) throws IOException {
-    if (rev.equals(ObjectId.zeroId())) {
-      return ImmutableSet.of();
-    }
-
     try (Timer0.Context ctx = readAllLatency.start();
-        RevWalk rw = new RevWalk(repo)) {
-      NoteMap noteMap = readNoteMap(rw, rev);
-      Set<ExternalId> extIds = new HashSet<>();
-      for (Note note : noteMap) {
-        byte[] raw =
-            rw.getObjectReader().open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-        try {
-          extIds.add(ExternalId.parse(note.getName(), raw, note.getData()));
-        } catch (Exception e) {
-          log.error(String.format("Ignoring invalid external ID note %s", note.getName()), e);
-        }
-      }
-      return extIds;
+        Repository repo = repoManager.openRepository(allUsersName)) {
+      return ExternalIdNotes.loadReadOnly(repo, rev).all();
     }
   }
 
@@ -152,14 +120,8 @@
   ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
     checkReadEnabled();
 
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev = readRevision(repo);
-      if (rev.equals(ObjectId.zeroId())) {
-        return null;
-      }
-
-      return parse(key, rw, rev);
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return ExternalIdNotes.loadReadOnly(repo).get(key).orElse(null);
     }
   }
 
@@ -168,27 +130,9 @@
   ExternalId get(ExternalId.Key key, ObjectId rev) throws IOException, ConfigInvalidException {
     checkReadEnabled();
 
-    if (rev.equals(ObjectId.zeroId())) {
-      return null;
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return ExternalIdNotes.loadReadOnly(repo, rev).get(key).orElse(null);
     }
-
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo)) {
-      return parse(key, rw, rev);
-    }
-  }
-
-  private static ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    NoteMap noteMap = readNoteMap(rw, rev);
-    ObjectId noteId = key.sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    ObjectId noteData = noteMap.get(noteId);
-    byte[] raw = rw.getObjectReader().open(noteData, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    return ExternalId.parse(noteId.name(), raw, noteData);
   }
 
   private void checkReadEnabled() throws IOException {
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index 35eb6d4..5732bce 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -43,12 +43,12 @@
   }
 
   /** Returns all external IDs. */
-  public Set<ExternalId> all() throws IOException {
+  public Set<ExternalId> all() throws IOException, ConfigInvalidException {
     return externalIdReader.all();
   }
 
   /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
-  public Set<ExternalId> all(ObjectId rev) throws IOException {
+  public Set<ExternalId> all(ObjectId rev) throws IOException, ConfigInvalidException {
     return externalIdReader.all(rev);
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
deleted file mode 100644
index 8e5582c..0000000
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account.externalids;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * This class allows to do batch updates to external IDs.
- *
- * <p>For NoteDb all updates will result in a single commit to the refs/meta/external-ids branch.
- * This means callers can prepare many updates by invoking {@link #replace(ExternalId, ExternalId)}
- * multiple times and when {@link ExternalIdsBatchUpdate#commit(String)} is invoked a single NoteDb
- * commit is created that contains all the prepared updates.
- */
-public class ExternalIdsBatchUpdate {
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final AllUsersName allUsersName;
-  private final PersonIdent serverIdent;
-  private final ExternalIdCache externalIdCache;
-  private final Set<ExternalId> toAdd = new HashSet<>();
-  private final Set<ExternalId> toDelete = new HashSet<>();
-
-  @Inject
-  public ExternalIdsBatchUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsersName,
-      @GerritPersonIdent PersonIdent serverIdent,
-      ExternalIdCache externalIdCache) {
-    this.repoManager = repoManager;
-    this.gitRefUpdated = gitRefUpdated;
-    this.allUsersName = allUsersName;
-    this.serverIdent = serverIdent;
-    this.externalIdCache = externalIdCache;
-  }
-
-  /**
-   * Adds an external ID replacement to the batch.
-   *
-   * <p>The actual replacement is only done when {@link #commit(String)} is invoked.
-   */
-  public void replace(ExternalId extIdToDelete, ExternalId extIdToAdd) {
-    ExternalIdsUpdate.checkSameAccount(ImmutableSet.of(extIdToDelete, extIdToAdd));
-    toAdd.add(extIdToAdd);
-    toDelete.add(extIdToDelete);
-  }
-
-  /**
-   * Commits this batch.
-   *
-   * <p>This means external ID replacements which were prepared by invoking {@link
-   * #replace(ExternalId, ExternalId)} are now executed. Deletion of external IDs is done before
-   * adding the new external IDs. This means if an external ID is specified for deletion and an
-   * external ID with the same key is specified to be added, the old external ID with that key is
-   * deleted first and then the new external ID is added (so the external ID for that key is
-   * replaced).
-   *
-   * <p>For NoteDb a single commit is created that contains all the external ID updates.
-   */
-  public void commit(String commitMessage)
-      throws IOException, OrmException, ConfigInvalidException {
-    if (toDelete.isEmpty() && toAdd.isEmpty()) {
-      return;
-    }
-
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId rev = ExternalIdReader.readRevision(repo);
-
-      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-      for (ExternalId extId : toDelete) {
-        ExternalIdsUpdate.remove(rw, noteMap, extId);
-      }
-
-      for (ExternalId extId : toAdd) {
-        ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
-      }
-
-      ObjectId newRev =
-          ExternalIdsUpdate.commit(
-              allUsersName,
-              repo,
-              rw,
-              ins,
-              rev,
-              noteMap,
-              commitMessage,
-              serverIdent,
-              serverIdent,
-              null,
-              gitRefUpdated);
-      externalIdCache.onReplace(rev, newRev, toDelete, toAdd);
-    }
-
-    toAdd.clear();
-    toDelete.clear();
-  }
-}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 5dbde8e..91619a3 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static java.util.stream.Collectors.joining;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -58,31 +57,29 @@
     this.validator = validator;
   }
 
-  public List<ConsistencyProblemInfo> check() throws IOException {
+  public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(repo, ExternalIdReader.readRevision(repo));
+      return check(ExternalIdNotes.loadReadOnly(repo));
     }
   }
 
-  public List<ConsistencyProblemInfo> check(ObjectId rev) throws IOException {
+  public List<ConsistencyProblemInfo> check(ObjectId rev)
+      throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(repo, rev);
+      return check(ExternalIdNotes.loadReadOnly(repo, rev));
     }
   }
 
-  private List<ConsistencyProblemInfo> check(Repository repo, ObjectId commit) throws IOException {
+  private List<ConsistencyProblemInfo> check(ExternalIdNotes extIdNotes) throws IOException {
     List<ConsistencyProblemInfo> problems = new ArrayList<>();
 
     ListMultimap<String, ExternalId.Key> emails =
         MultimapBuilder.hashKeys().arrayListValues().build();
 
-    try (RevWalk rw = new RevWalk(repo)) {
-      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, commit);
+    try (RevWalk rw = new RevWalk(extIdNotes.getRepository())) {
+      NoteMap noteMap = extIdNotes.getNoteMap();
       for (Note note : noteMap) {
-        byte[] raw =
-            rw.getObjectReader()
-                .open(note.getData(), OBJ_BLOB)
-                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
+        byte[] raw = ExternalIdNotes.readNoteData(rw, note.getData());
         try {
           ExternalId extId = ExternalId.parse(note.getName(), raw, note.getData());
           problems.addAll(validateExternalId(extId));
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
index 2e58699..8a05a6c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
@@ -15,35 +15,18 @@
 package com.google.gerrit.server.account.externalids;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.account.externalids.ExternalIdReader.MAX_NOTE_SZ;
-import static com.google.gerrit.server.account.externalids.ExternalIdReader.readNoteMap;
-import static com.google.gerrit.server.account.externalids.ExternalIdReader.readRevision;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toSet;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
@@ -53,20 +36,8 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Updates externalIds in ReviewDb and NoteDb.
@@ -89,8 +60,6 @@
  * cache and thus triggers reindex for them.
  */
 public class ExternalIdsUpdate {
-  private static final String COMMIT_MSG = "Update external IDs";
-
   /**
    * Factory to create an ExternalIdsUpdate instance for updating external IDs by the Gerrit server.
    *
@@ -100,50 +69,43 @@
   @Singleton
   public static class Server {
     private final GitRepositoryManager repoManager;
+    private final Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
     private final AccountCache accountCache;
     private final AllUsersName allUsersName;
     private final MetricMaker metricMaker;
     private final ExternalIds externalIds;
     private final ExternalIdCache externalIdCache;
-    private final Provider<PersonIdent> serverIdent;
-    private final GitReferenceUpdated gitRefUpdated;
     private final RetryHelper retryHelper;
 
     @Inject
     public Server(
         GitRepositoryManager repoManager,
+        Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory,
         AccountCache accountCache,
         AllUsersName allUsersName,
         MetricMaker metricMaker,
         ExternalIds externalIds,
         ExternalIdCache externalIdCache,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        GitReferenceUpdated gitRefUpdated,
         RetryHelper retryHelper) {
       this.repoManager = repoManager;
+      this.metaDataUpdateServerFactory = metaDataUpdateServerFactory;
       this.accountCache = accountCache;
       this.allUsersName = allUsersName;
       this.metricMaker = metricMaker;
       this.externalIds = externalIds;
       this.externalIdCache = externalIdCache;
-      this.serverIdent = serverIdent;
-      this.gitRefUpdated = gitRefUpdated;
       this.retryHelper = retryHelper;
     }
 
     public ExternalIdsUpdate create() {
-      PersonIdent i = serverIdent.get();
       return new ExternalIdsUpdate(
           repoManager,
+          () -> metaDataUpdateServerFactory.get().create(allUsersName),
           accountCache,
           allUsersName,
           metricMaker,
           externalIds,
           externalIdCache,
-          i,
-          i,
-          null,
-          gitRefUpdated,
           retryHelper);
     }
   }
@@ -160,47 +122,40 @@
   @Singleton
   public static class ServerNoReindex {
     private final GitRepositoryManager repoManager;
+    private final Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
     private final AllUsersName allUsersName;
     private final MetricMaker metricMaker;
     private final ExternalIds externalIds;
     private final ExternalIdCache externalIdCache;
-    private final Provider<PersonIdent> serverIdent;
-    private final GitReferenceUpdated gitRefUpdated;
     private final RetryHelper retryHelper;
 
     @Inject
     public ServerNoReindex(
         GitRepositoryManager repoManager,
+        Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory,
         AllUsersName allUsersName,
         MetricMaker metricMaker,
         ExternalIds externalIds,
         ExternalIdCache externalIdCache,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        GitReferenceUpdated gitRefUpdated,
         RetryHelper retryHelper) {
       this.repoManager = repoManager;
+      this.metaDataUpdateServerFactory = metaDataUpdateServerFactory;
       this.allUsersName = allUsersName;
       this.metricMaker = metricMaker;
       this.externalIds = externalIds;
       this.externalIdCache = externalIdCache;
-      this.serverIdent = serverIdent;
-      this.gitRefUpdated = gitRefUpdated;
       this.retryHelper = retryHelper;
     }
 
     public ExternalIdsUpdate create() {
-      PersonIdent i = serverIdent.get();
       return new ExternalIdsUpdate(
           repoManager,
+          () -> metaDataUpdateServerFactory.get().create(allUsersName),
           null,
           allUsersName,
           metricMaker,
           externalIds,
           externalIdCache,
-          i,
-          i,
-          null,
-          gitRefUpdated,
           retryHelper);
     }
   }
@@ -214,98 +169,74 @@
   @Singleton
   public static class User {
     private final GitRepositoryManager repoManager;
+    private final Provider<MetaDataUpdate.User> metaDataUpdateUserFactory;
     private final AccountCache accountCache;
     private final AllUsersName allUsersName;
     private final MetricMaker metricMaker;
     private final ExternalIds externalIds;
     private final ExternalIdCache externalIdCache;
-    private final Provider<PersonIdent> serverIdent;
-    private final Provider<IdentifiedUser> identifiedUser;
-    private final GitReferenceUpdated gitRefUpdated;
     private final RetryHelper retryHelper;
 
     @Inject
     public User(
         GitRepositoryManager repoManager,
+        Provider<MetaDataUpdate.User> metaDataUpdateUserFactory,
         AccountCache accountCache,
         AllUsersName allUsersName,
         MetricMaker metricMaker,
         ExternalIds externalIds,
         ExternalIdCache externalIdCache,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        Provider<IdentifiedUser> identifiedUser,
-        GitReferenceUpdated gitRefUpdated,
         RetryHelper retryHelper) {
       this.repoManager = repoManager;
+      this.metaDataUpdateUserFactory = metaDataUpdateUserFactory;
       this.accountCache = accountCache;
       this.allUsersName = allUsersName;
       this.metricMaker = metricMaker;
       this.externalIds = externalIds;
       this.externalIdCache = externalIdCache;
-      this.serverIdent = serverIdent;
-      this.identifiedUser = identifiedUser;
-      this.gitRefUpdated = gitRefUpdated;
       this.retryHelper = retryHelper;
     }
 
     public ExternalIdsUpdate create() {
-      IdentifiedUser user = identifiedUser.get();
-      PersonIdent i = serverIdent.get();
       return new ExternalIdsUpdate(
           repoManager,
+          () -> metaDataUpdateUserFactory.get().create(allUsersName),
           accountCache,
           allUsersName,
           metricMaker,
           externalIds,
           externalIdCache,
-          createPersonIdent(i, user),
-          i,
-          user,
-          gitRefUpdated,
           retryHelper);
     }
-
-    private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
-      return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
-    }
   }
 
   private final GitRepositoryManager repoManager;
+  private final MetaDataUpdateFactory metaDataUpdateFactory;
   @Nullable private final AccountCache accountCache;
   private final AllUsersName allUsersName;
   private final ExternalIds externalIds;
   private final ExternalIdCache externalIdCache;
-  private final PersonIdent committerIdent;
-  private final PersonIdent authorIdent;
-  @Nullable private final IdentifiedUser currentUser;
-  private final GitReferenceUpdated gitRefUpdated;
   private final RetryHelper retryHelper;
   private final Runnable afterReadRevision;
   private final Counter0 updateCount;
 
   private ExternalIdsUpdate(
       GitRepositoryManager repoManager,
+      MetaDataUpdateFactory metaDataUpdateFactory,
       @Nullable AccountCache accountCache,
       AllUsersName allUsersName,
       MetricMaker metricMaker,
       ExternalIds externalIds,
       ExternalIdCache externalIdCache,
-      PersonIdent committerIdent,
-      PersonIdent authorIdent,
-      @Nullable IdentifiedUser currentUser,
-      GitReferenceUpdated gitRefUpdated,
       RetryHelper retryHelper) {
     this(
         repoManager,
+        metaDataUpdateFactory,
         accountCache,
         allUsersName,
         metricMaker,
         externalIds,
         externalIdCache,
-        committerIdent,
-        authorIdent,
-        currentUser,
-        gitRefUpdated,
         retryHelper,
         Runnables.doNothing());
   }
@@ -313,26 +244,20 @@
   @VisibleForTesting
   public ExternalIdsUpdate(
       GitRepositoryManager repoManager,
+      MetaDataUpdateFactory metaDataUpdateFactory,
       @Nullable AccountCache accountCache,
       AllUsersName allUsersName,
       MetricMaker metricMaker,
       ExternalIds externalIds,
       ExternalIdCache externalIdCache,
-      PersonIdent committerIdent,
-      PersonIdent authorIdent,
-      @Nullable IdentifiedUser currentUser,
-      GitReferenceUpdated gitRefUpdated,
       RetryHelper retryHelper,
       Runnable afterReadRevision) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.metaDataUpdateFactory = checkNotNull(metaDataUpdateFactory, "metaDataUpdateFactory");
     this.accountCache = accountCache;
     this.allUsersName = checkNotNull(allUsersName, "allUsersName");
-    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
     this.externalIds = checkNotNull(externalIds, "externalIds");
     this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
-    this.authorIdent = checkNotNull(authorIdent, "authorIdent");
-    this.currentUser = currentUser;
-    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
     this.retryHelper = checkNotNull(retryHelper, "retryHelper");
     this.afterReadRevision = checkNotNull(afterReadRevision, "afterReadRevision");
     this.updateCount =
@@ -358,18 +283,7 @@
    */
   public void insert(Collection<ExternalId> extIds)
       throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId extId : extIds) {
-                ExternalId insertedExtId = insert(o.rw(), o.ins(), o.noteMap(), extId);
-                updatedExtIds.onUpdate(insertedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onCreate(u.oldRev(), u.newRev(), u.updatedExtIds().getUpdated());
-    evictAccounts(u);
+    updateNoteMap(n -> n.insert(extIds));
   }
 
   /**
@@ -388,18 +302,7 @@
    */
   public void upsert(Collection<ExternalId> extIds)
       throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId extId : extIds) {
-                ExternalId updatedExtId = upsert(o.rw(), o.ins(), o.noteMap(), extId);
-                updatedExtIds.onUpdate(updatedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onUpdate(u.oldRev(), u.newRev(), u.updatedExtIds().getUpdated());
-    evictAccounts(u);
+    updateNoteMap(n -> n.upsert(extIds));
   }
 
   /**
@@ -421,18 +324,7 @@
    */
   public void delete(Collection<ExternalId> extIds)
       throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId extId : extIds) {
-                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extId);
-                updatedExtIds.onRemove(removedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onRemove(u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved());
-    evictAccounts(u);
+    updateNoteMap(n -> n.delete(extIds));
   }
 
   /**
@@ -454,18 +346,7 @@
    */
   public void delete(Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
       throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId.Key extIdKey : extIdKeys) {
-                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extIdKey, accountId);
-                updatedExtIds.onRemove(removedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onRemove(u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved());
-    evictAccount(accountId);
+    updateNoteMap(n -> n.delete(accountId, extIdKeys));
   }
 
   /**
@@ -475,18 +356,7 @@
    */
   public void deleteByKeys(Collection<ExternalId.Key> extIdKeys)
       throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId.Key extIdKey : extIdKeys) {
-                ExternalId extId = remove(o.rw(), o.noteMap(), extIdKey, null);
-                updatedExtIds.onRemove(extId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onRemove(u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved());
-    evictAccounts(u);
+    updateNoteMap(n -> n.deleteByKeys(extIdKeys));
   }
 
   /** Deletes all external IDs of the specified account. */
@@ -509,30 +379,7 @@
   public void replace(
       Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
       throws IOException, ConfigInvalidException, OrmException {
-    checkSameAccount(toAdd, accountId);
-
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId.Key extIdKey : toDelete) {
-                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extIdKey, accountId);
-                updatedExtIds.onRemove(removedExtId);
-              }
-
-              for (ExternalId extId : toAdd) {
-                ExternalId insertedExtId = insert(o.rw(), o.ins(), o.noteMap(), extId);
-                updatedExtIds.onUpdate(insertedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onReplace(
-        u.oldRev(),
-        u.newRev(),
-        accountId,
-        u.updatedExtIds().getRemoved(),
-        u.updatedExtIds().getUpdated());
-    evictAccount(accountId);
+    updateNoteMap(n -> n.replace(accountId, toDelete, toAdd));
   }
 
   /**
@@ -547,24 +394,7 @@
    */
   public void replaceByKeys(Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
       throws IOException, ConfigInvalidException, OrmException {
-    RefsMetaExternalIdsUpdate u =
-        updateNoteMap(
-            o -> {
-              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
-              for (ExternalId.Key extIdKey : toDelete) {
-                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extIdKey, null);
-                updatedExtIds.onRemove(removedExtId);
-              }
-
-              for (ExternalId extId : toAdd) {
-                ExternalId insertedExtId = insert(o.rw(), o.ins(), o.noteMap(), extId);
-                updatedExtIds.onUpdate(insertedExtId);
-              }
-              return updatedExtIds;
-            });
-    externalIdCache.onReplace(
-        u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved(), u.updatedExtIds().getUpdated());
-    evictAccounts(u);
+    updateNoteMap(n -> n.replaceByKeys(toDelete, toAdd));
   }
 
   /**
@@ -591,334 +421,38 @@
    */
   public void replace(Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
       throws IOException, ConfigInvalidException, OrmException {
-    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
-    if (accountId == null) {
-      // toDelete and toAdd are empty -> nothing to do
-      return;
-    }
-
-    replace(accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
+    updateNoteMap(n -> n.replace(toDelete, toAdd));
   }
 
-  /**
-   * Checks that all specified external IDs belong to the same account.
-   *
-   * @return the ID of the account to which all specified external IDs belong.
-   */
-  public static Account.Id checkSameAccount(Iterable<ExternalId> extIds) {
-    return checkSameAccount(extIds, null);
-  }
-
-  /**
-   * Checks that all specified external IDs belong to specified account. If no account is specified
-   * it is checked that all specified external IDs belong to the same account.
-   *
-   * @return the ID of the account to which all specified external IDs belong.
-   */
-  public static Account.Id checkSameAccount(
-      Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
-    for (ExternalId extId : extIds) {
-      if (accountId == null) {
-        accountId = extId.accountId();
-        continue;
-      }
-      checkState(
-          accountId.equals(extId.accountId()),
-          "external id %s belongs to account %s, expected account %s",
-          extId.key().get(),
-          extId.accountId().get(),
-          accountId.get());
-    }
-    return accountId;
-  }
-
-  /**
-   * Inserts a new external ID and sets it in the note map.
-   *
-   * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
-   */
-  public static ExternalId insert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
-      throws OrmDuplicateKeyException, ConfigInvalidException, IOException {
-    if (noteMap.contains(extId.key().sha1())) {
-      throw new OrmDuplicateKeyException(
-          String.format("external id %s already exists", extId.key().get()));
-    }
-    return upsert(rw, ins, noteMap, extId);
-  }
-
-  /**
-   * Insert or updates an new external ID and sets it in the note map.
-   *
-   * <p>If the external ID already exists it is overwritten.
-   */
-  public static ExternalId upsert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
-      throws IOException, ConfigInvalidException {
-    ObjectId noteId = extId.key().sha1();
-    Config c = new Config();
-    if (noteMap.contains(extId.key().sha1())) {
-      byte[] raw =
-          rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-      try {
-        c.fromText(new String(raw, UTF_8));
-      } catch (ConfigInvalidException e) {
-        throw new ConfigInvalidException(
-            String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
-      }
-    }
-    extId.writeToConfig(c);
-    byte[] raw = c.toText().getBytes(UTF_8);
-    ObjectId noteData = ins.insert(OBJ_BLOB, raw);
-    noteMap.set(noteId, noteData);
-    return ExternalId.create(extId, noteData);
-  }
-
-  /**
-   * Removes an external ID from the note map.
-   *
-   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
-   *     key, but otherwise doesn't match the specified external ID.
-   */
-  public static ExternalId remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
-      throws IOException, ConfigInvalidException {
-    ObjectId noteId = extId.key().sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    ObjectId noteData = noteMap.get(noteId);
-    byte[] raw = rw.getObjectReader().open(noteData, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    ExternalId actualExtId = ExternalId.parse(noteId.name(), raw, noteData);
-    checkState(
-        extId.equals(actualExtId),
-        "external id %s should be removed, but it's not matching the actual external id %s",
-        extId.toString(),
-        actualExtId.toString());
-    noteMap.remove(noteId);
-    return actualExtId;
-  }
-
-  /**
-   * Removes an external ID from the note map by external ID key.
-   *
-   * @throws IllegalStateException is thrown if an expected account ID is provided and an external
-   *     ID with the specified key exists, but belongs to another account.
-   * @return the external ID that was removed, {@code null} if no external ID with the specified key
-   *     exists
-   */
-  private static ExternalId remove(
-      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
-      throws IOException, ConfigInvalidException {
-    ObjectId noteId = extIdKey.sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    ObjectId noteData = noteMap.get(noteId);
-    byte[] raw = rw.getObjectReader().open(noteData, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    ExternalId extId = ExternalId.parse(noteId.name(), raw, noteData);
-    if (expectedAccountId != null) {
-      checkState(
-          expectedAccountId.equals(extId.accountId()),
-          "external id %s should be removed for account %s,"
-              + " but external id belongs to account %s",
-          extIdKey.get(),
-          expectedAccountId.get(),
-          extId.accountId().get());
-    }
-    noteMap.remove(noteId);
-    return extId;
-  }
-
-  private RefsMetaExternalIdsUpdate updateNoteMap(ExternalIdUpdater updater)
+  private void updateNoteMap(ExternalIdUpdater updater)
       throws IOException, ConfigInvalidException, OrmException {
-    return retryHelper.execute(
+    retryHelper.execute(
         () -> {
-          try (Repository repo = repoManager.openRepository(allUsersName);
-              ObjectInserter ins = repo.newObjectInserter()) {
-            ObjectId rev = readRevision(repo);
-
-            afterReadRevision.run();
-
-            try (RevWalk rw = new RevWalk(repo)) {
-              NoteMap noteMap = readNoteMap(rw, rev);
-              UpdatedExternalIds updatedExtIds =
-                  updater.update(OpenRepo.create(repo, rw, ins, noteMap));
-
-              return commit(repo, rw, ins, rev, noteMap, updatedExtIds);
+          try (Repository repo = repoManager.openRepository(allUsersName)) {
+            ExternalIdNotes extIdNotes =
+                new ExternalIdNotes(externalIdCache, accountCache, repo)
+                    .setAfterReadRevision(afterReadRevision)
+                    .load();
+            updater.update(extIdNotes);
+            try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create()) {
+              extIdNotes.commit(metaDataUpdate);
             }
+            extIdNotes.updateCaches();
+            updateCount.increment();
+            return null;
           }
         });
   }
 
-  private RefsMetaExternalIdsUpdate commit(
-      Repository repo,
-      RevWalk rw,
-      ObjectInserter ins,
-      ObjectId rev,
-      NoteMap noteMap,
-      UpdatedExternalIds updatedExtIds)
-      throws IOException {
-    ObjectId newRev =
-        commit(
-            allUsersName,
-            repo,
-            rw,
-            ins,
-            rev,
-            noteMap,
-            COMMIT_MSG,
-            committerIdent,
-            authorIdent,
-            currentUser,
-            gitRefUpdated);
-    updateCount.increment();
-    return RefsMetaExternalIdsUpdate.create(rev, newRev, updatedExtIds);
-  }
-
-  /** Commits updates to the external IDs. */
-  public static ObjectId commit(
-      Project.NameKey project,
-      Repository repo,
-      RevWalk rw,
-      ObjectInserter ins,
-      ObjectId rev,
-      NoteMap noteMap,
-      String commitMessage,
-      PersonIdent committerIdent,
-      PersonIdent authorIdent,
-      @Nullable IdentifiedUser user,
-      GitReferenceUpdated gitRefUpdated)
-      throws IOException {
-    CommitBuilder cb = new CommitBuilder();
-    cb.setMessage(commitMessage);
-    cb.setTreeId(noteMap.writeTree(ins));
-    cb.setAuthor(authorIdent);
-    cb.setCommitter(committerIdent);
-    if (!rev.equals(ObjectId.zeroId())) {
-      cb.setParentId(rev);
-    } else {
-      cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
-    }
-    if (cb.getTreeId() == null) {
-      if (rev.equals(ObjectId.zeroId())) {
-        cb.setTreeId(emptyTree(ins)); // No parent, assume empty tree.
-      } else {
-        RevCommit p = rw.parseCommit(rev);
-        cb.setTreeId(p.getTree()); // Copy tree from parent.
-      }
-    }
-    ObjectId commitId = ins.insert(cb);
-    ins.flush();
-
-    RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
-    u.setRefLogIdent(committerIdent);
-    u.setRefLogMessage("Update external IDs", false);
-    u.setExpectedOldObjectId(rev);
-    u.setNewObjectId(commitId);
-    RefUpdate.Result res = u.update();
-    switch (res) {
-      case NEW:
-      case FAST_FORWARD:
-      case NO_CHANGE:
-      case RENAMED:
-      case FORCED:
-        break;
-      case LOCK_FAILURE:
-        throw new LockFailureException("Updating external IDs failed with " + res, u);
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      default:
-        throw new IOException("Updating external IDs failed with " + res);
-    }
-    gitRefUpdated.fire(project, u, user != null ? user.getAccount() : null);
-    return rw.parseCommit(commitId);
-  }
-
-  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
-    return ins.insert(OBJ_TREE, new byte[] {});
-  }
-
-  private void evictAccount(Account.Id accountId) throws IOException {
-    if (accountCache != null) {
-      accountCache.evict(accountId);
-    }
-  }
-
-  private void evictAccounts(RefsMetaExternalIdsUpdate u) throws IOException {
-    if (accountCache != null) {
-      for (Account.Id id : u.updatedExtIds().all().map(ExternalId::accountId).collect(toSet())) {
-        accountCache.evict(id);
-      }
-    }
-  }
-
   @FunctionalInterface
   private static interface ExternalIdUpdater {
-    UpdatedExternalIds update(OpenRepo openRepo)
+    void update(ExternalIdNotes extIdsNotes)
         throws IOException, ConfigInvalidException, OrmException;
   }
 
-  @AutoValue
-  abstract static class OpenRepo {
-    static OpenRepo create(Repository repo, RevWalk rw, ObjectInserter ins, NoteMap noteMap) {
-      return new AutoValue_ExternalIdsUpdate_OpenRepo(repo, rw, ins, noteMap);
-    }
-
-    abstract Repository repo();
-
-    abstract RevWalk rw();
-
-    abstract ObjectInserter ins();
-
-    abstract NoteMap noteMap();
-  }
-
   @VisibleForTesting
-  @AutoValue
-  public abstract static class RefsMetaExternalIdsUpdate {
-    static RefsMetaExternalIdsUpdate create(
-        ObjectId oldRev, ObjectId newRev, UpdatedExternalIds updatedExtIds) {
-      return new AutoValue_ExternalIdsUpdate_RefsMetaExternalIdsUpdate(
-          oldRev, newRev, updatedExtIds);
-    }
-
-    abstract ObjectId oldRev();
-
-    abstract ObjectId newRev();
-
-    abstract UpdatedExternalIds updatedExtIds();
-  }
-
-  public static class UpdatedExternalIds {
-    private Set<ExternalId> updated = new HashSet<>();
-    private Set<ExternalId> removed = new HashSet<>();
-
-    public void onUpdate(ExternalId extId) {
-      if (extId != null) {
-        updated.add(extId);
-      }
-    }
-
-    public void onRemove(ExternalId extId) {
-      if (extId != null) {
-        removed.add(extId);
-      }
-    }
-
-    public Set<ExternalId> getUpdated() {
-      return ImmutableSet.copyOf(updated);
-    }
-
-    public Set<ExternalId> getRemoved() {
-      return ImmutableSet.copyOf(removed);
-    }
-
-    public Stream<ExternalId> all() {
-      return Streams.concat(removed.stream(), updated.stream());
-    }
+  @FunctionalInterface
+  public static interface MetaDataUpdateFactory {
+    MetaDataUpdate create() throws IOException;
   }
 }
diff --git a/java/com/google/gerrit/server/api/BUILD b/java/com/google/gerrit/server/api/BUILD
index 2741a0a..910ecd3 100644
--- a/java/com/google/gerrit/server/api/BUILD
+++ b/java/com/google/gerrit/server/api/BUILD
@@ -11,6 +11,7 @@
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/restapi",
         "//lib:guava",
         "//lib:gwtorm",
         "//lib:servlet-api-3_1",
diff --git a/java/com/google/gerrit/server/api/config/ServerImpl.java b/java/com/google/gerrit/server/api/config/ServerImpl.java
index 2148d97..ce87d1c 100644
--- a/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -24,13 +24,13 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.config.CheckConsistency;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.GetDiffPreferences;
-import com.google.gerrit.server.config.GetPreferences;
-import com.google.gerrit.server.config.GetServerInfo;
-import com.google.gerrit.server.config.SetDiffPreferences;
-import com.google.gerrit.server.config.SetPreferences;
+import com.google.gerrit.server.restapi.config.CheckConsistency;
+import com.google.gerrit.server.restapi.config.GetDiffPreferences;
+import com.google.gerrit.server.restapi.config.GetPreferences;
+import com.google.gerrit.server.restapi.config.GetServerInfo;
+import com.google.gerrit.server.restapi.config.SetDiffPreferences;
+import com.google.gerrit.server.restapi.config.SetPreferences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/change/ChangesCollection.java b/java/com/google/gerrit/server/change/ChangesCollection.java
index a0b6c96..6ce661e 100644
--- a/java/com/google/gerrit/server/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/change/ChangesCollection.java
@@ -81,8 +81,8 @@
 
   @Override
   public ChangeResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException, OrmException, PermissionBackendException {
-    List<ChangeNotes> notes = changeFinder.find(id.encoded());
+      throws RestApiException, OrmException, PermissionBackendException {
+    List<ChangeNotes> notes = changeFinder.find(id.encoded(), true);
     if (notes.isEmpty()) {
       throw new ResourceNotFoundException(id);
     } else if (notes.size() != 1) {
diff --git a/java/com/google/gerrit/server/change/DeletePrivate.java b/java/com/google/gerrit/server/change/DeletePrivate.java
index bb3a38a..ba5403a 100644
--- a/java/com/google/gerrit/server/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/change/DeletePrivate.java
@@ -40,17 +40,20 @@
   private final ChangeMessagesUtil cmUtil;
   private final Provider<ReviewDb> dbProvider;
   private final PermissionBackend permissionBackend;
+  private final SetPrivateOp.Factory setPrivateOpFactory;
 
   @Inject
   DeletePrivate(
       Provider<ReviewDb> dbProvider,
       RetryHelper retryHelper,
       ChangeMessagesUtil cmUtil,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      SetPrivateOp.Factory setPrivateOpFactory) {
     super(retryHelper);
     this.dbProvider = dbProvider;
     this.cmUtil = cmUtil;
     this.permissionBackend = permissionBackend;
+    this.setPrivateOpFactory = setPrivateOpFactory;
   }
 
   @Override
@@ -65,7 +68,7 @@
       throw new ResourceConflictException("change is not private");
     }
 
-    SetPrivateOp op = new SetPrivateOp(cmUtil, false, input);
+    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, false, input);
     try (BatchUpdate u =
         updateFactory.create(
             dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
diff --git a/java/com/google/gerrit/server/change/DeletePrivateByPost.java b/java/com/google/gerrit/server/change/DeletePrivateByPost.java
index 2de57eb..a392492 100644
--- a/java/com/google/gerrit/server/change/DeletePrivateByPost.java
+++ b/java/com/google/gerrit/server/change/DeletePrivateByPost.java
@@ -32,8 +32,9 @@
       Provider<ReviewDb> dbProvider,
       RetryHelper retryHelper,
       ChangeMessagesUtil cmUtil,
-      PermissionBackend permissionBackend) {
-    super(dbProvider, retryHelper, cmUtil, permissionBackend);
+      PermissionBackend permissionBackend,
+      SetPrivateOp.Factory setPrivateOpFactory) {
+    super(dbProvider, retryHelper, cmUtil, permissionBackend, setPrivateOpFactory);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/change/Module.java b/java/com/google/gerrit/server/change/Module.java
index f648d5a..b4f71af 100644
--- a/java/com/google/gerrit/server/change/Module.java
+++ b/java/com/google/gerrit/server/change/Module.java
@@ -177,6 +177,7 @@
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
+    factory(SetPrivateOp.Factory.class);
     factory(WorkInProgressOp.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/change/PostPrivate.java b/java/com/google/gerrit/server/change/PostPrivate.java
index ac0b4c2..307d6df 100644
--- a/java/com/google/gerrit/server/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/change/PostPrivate.java
@@ -43,17 +43,20 @@
   private final ChangeMessagesUtil cmUtil;
   private final Provider<ReviewDb> dbProvider;
   private final PermissionBackend permissionBackend;
+  private final SetPrivateOp.Factory setPrivateOpFactory;
 
   @Inject
   PostPrivate(
       Provider<ReviewDb> dbProvider,
       RetryHelper retryHelper,
       ChangeMessagesUtil cmUtil,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      SetPrivateOp.Factory setPrivateOpFactory) {
     super(retryHelper);
     this.dbProvider = dbProvider;
     this.cmUtil = cmUtil;
     this.permissionBackend = permissionBackend;
+    this.setPrivateOpFactory = setPrivateOpFactory;
   }
 
   @Override
@@ -68,7 +71,7 @@
       return Response.ok("");
     }
 
-    SetPrivateOp op = new SetPrivateOp(cmUtil, true, input);
+    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, true, input);
     try (BatchUpdate u =
         updateFactory.create(
             dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
diff --git a/java/com/google/gerrit/server/change/SetPrivateOp.java b/java/com/google/gerrit/server/change/SetPrivateOp.java
index d0bb70b..9aa4636 100644
--- a/java/com/google/gerrit/server/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -19,10 +19,14 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.extensions.events.PrivateStateChanged;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
 
 public class SetPrivateOp implements BatchUpdateOp {
   public static class Input {
@@ -35,19 +39,32 @@
     }
   }
 
+  public interface Factory {
+    SetPrivateOp create(ChangeMessagesUtil cmUtil, boolean isPrivate, Input input);
+  }
+
   private final ChangeMessagesUtil cmUtil;
   private final boolean isPrivate;
   private final Input input;
+  private final PrivateStateChanged privateStateChanged;
 
-  SetPrivateOp(ChangeMessagesUtil cmUtil, boolean isPrivate, Input input) {
+  private Change change;
+
+  @Inject
+  SetPrivateOp(
+      PrivateStateChanged privateStateChanged,
+      @Assisted ChangeMessagesUtil cmUtil,
+      @Assisted boolean isPrivate,
+      @Assisted Input input) {
     this.cmUtil = cmUtil;
     this.isPrivate = isPrivate;
     this.input = input;
+    this.privateStateChanged = privateStateChanged;
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
-    Change change = ctx.getChange();
+    change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setPrivate(isPrivate);
     change.setLastUpdatedOn(ctx.getWhen());
@@ -56,6 +73,11 @@
     return true;
   }
 
+  @Override
+  public void postUpdate(Context ctx) {
+    privateStateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
+  }
+
   private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
     Change c = ctx.getChange();
     StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index b5a8220..43de55c3 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -58,6 +59,7 @@
   private final boolean workInProgress;
   private final Input in;
   private final NotifyHandling notify;
+  private final WorkInProgressStateChanged stateChanged;
 
   private Change change;
   private ChangeNotes notes;
@@ -69,11 +71,13 @@
       ChangeMessagesUtil cmUtil,
       EmailReviewComments.Factory email,
       PatchSetUtil psUtil,
+      WorkInProgressStateChanged stateChanged,
       @Assisted boolean workInProgress,
       @Assisted Input in) {
     this.cmUtil = cmUtil;
     this.email = email;
     this.psUtil = psUtil;
+    this.stateChanged = stateChanged;
     this.workInProgress = workInProgress;
     this.in = in;
     notify =
@@ -121,6 +125,7 @@
 
   @Override
   public void postUpdate(Context ctx) {
+    stateChanged.fire(change, ctx.getAccount(), ctx.getWhen());
     if (workInProgress || notify.ordinal() < NotifyHandling.OWNER_REVIEWERS.ordinal()) {
       return;
     }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 224bf29..e0d4827 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.PluginEventListener;
+import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
@@ -54,6 +55,7 @@
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.UsageDataPublishedListener;
 import com.google.gerrit.extensions.events.VoteDeletedListener;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -173,6 +175,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.restapi.config.ConfigRestModule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.gerrit.server.ssh.SshAddressesModule;
@@ -305,7 +308,7 @@
     install(new com.google.gerrit.server.access.Module());
     install(new com.google.gerrit.server.account.Module());
     install(new com.google.gerrit.server.change.Module());
-    install(new com.google.gerrit.server.config.Module());
+    install(new ConfigRestModule());
     install(new com.google.gerrit.server.group.Module(groupsMigration));
     install(new com.google.gerrit.server.project.Module());
 
@@ -325,9 +328,11 @@
 
     DynamicSet.setOf(binder(), ChangeRestoredListener.class);
     DynamicSet.setOf(binder(), ChangeRevertedListener.class);
+    DynamicSet.setOf(binder(), PrivateStateChangedListener.class);
     DynamicSet.setOf(binder(), ReviewerAddedListener.class);
     DynamicSet.setOf(binder(), ReviewerDeletedListener.class);
     DynamicSet.setOf(binder(), VoteDeletedListener.class);
+    DynamicSet.setOf(binder(), WorkInProgressStateChangedListener.class);
     DynamicSet.setOf(binder(), RevisionCreatedListener.class);
     DynamicSet.setOf(binder(), TopicEditedListener.class);
     DynamicSet.setOf(binder(), AgreementSignupListener.class);
diff --git a/java/com/google/gerrit/server/data/ChangeAttribute.java b/java/com/google/gerrit/server/data/ChangeAttribute.java
index 0467c92..ec76f50 100644
--- a/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gson.annotations.SerializedName;
 import java.util.List;
 
 public class ChangeAttribute {
@@ -35,6 +36,10 @@
   public Boolean open;
   public Change.Status status;
   public List<MessageAttribute> comments;
+  public Boolean wip;
+
+  @SerializedName("private")
+  public Boolean isPrivate;
 
   public List<TrackingIdAttribute> trackingIds;
   public PatchSetAttribute currentPatchSet;
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 85b91d0..2759de0 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -158,6 +158,8 @@
     a.assignee = asAccountAttribute(change.getAssignee());
     a.status = change.getStatus();
     a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.wip = change.isWorkInProgress() ? true : null;
+    a.isPrivate = change.isPrivate() ? true : null;
     return a;
   }
 
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index f261c02..cd2b464 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -30,6 +30,7 @@
     register(CommitReceivedEvent.TYPE, CommitReceivedEvent.class);
     register(HashtagsChangedEvent.TYPE, HashtagsChangedEvent.class);
     register(PatchSetCreatedEvent.TYPE, PatchSetCreatedEvent.class);
+    register(PrivateStateChangedEvent.TYPE, PrivateStateChangedEvent.class);
     register(ProjectCreatedEvent.TYPE, ProjectCreatedEvent.class);
     register(RefReceivedEvent.TYPE, RefReceivedEvent.class);
     register(RefUpdatedEvent.TYPE, RefUpdatedEvent.class);
@@ -37,6 +38,7 @@
     register(ReviewerDeletedEvent.TYPE, ReviewerDeletedEvent.class);
     register(TopicChangedEvent.TYPE, TopicChangedEvent.class);
     register(VoteDeletedEvent.TYPE, VoteDeletedEvent.class);
+    register(WorkInProgressStateChangedEvent.TYPE, WorkInProgressStateChangedEvent.class);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
new file mode 100644
index 0000000..af42b08
--- /dev/null
+++ b/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class PrivateStateChangedEvent extends ChangeEvent {
+  static final String TYPE = "private-state-changed";
+  public Supplier<AccountAttribute> changer;
+
+  protected PrivateStateChangedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 96ef7990..a63e1f8 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -31,11 +31,13 @@
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
 import com.google.gerrit.extensions.events.ReviewerDeletedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.VoteDeletedListener;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
@@ -76,6 +78,8 @@
         ChangeAbandonedListener,
         ChangeMergedListener,
         ChangeRestoredListener,
+        WorkInProgressStateChangedListener,
+        PrivateStateChangedListener,
         CommentAddedListener,
         GitReferenceUpdatedListener,
         HashtagsEditedListener,
@@ -99,11 +103,15 @@
           .to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), HashtagsEditedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), PrivateStateChangedListener.class)
+          .to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ReviewerAddedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ReviewerDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), RevisionCreatedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), TopicEditedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), WorkInProgressStateChangedListener.class)
+          .to(StreamEventsApiListener.class);
     }
   }
 
@@ -462,6 +470,36 @@
   }
 
   @Override
+  public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      WorkInProgressStateChangedEvent event = new WorkInProgressStateChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onPrivateStateChanged(PrivateStateChangedListener.Event ev) {
+    try {
+      Change change = getChange(ev.getChange());
+      PrivateStateChangedEvent event = new PrivateStateChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
   public void onVoteDeleted(VoteDeletedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
diff --git a/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
new file mode 100644
index 0000000..ad32672
--- /dev/null
+++ b/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class WorkInProgressStateChangedEvent extends ChangeEvent {
+  static final String TYPE = "wip-state-changed";
+  public Supplier<AccountAttribute> changer;
+
+  protected WorkInProgressStateChangedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
new file mode 100644
index 0000000..37c9089
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.PrivateStateChangedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PrivateStateChanged {
+  private static final Logger log = LoggerFactory.getLogger(PrivateStateChanged.class);
+
+  private final DynamicSet<PrivateStateChangedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  PrivateStateChanged(DynamicSet<PrivateStateChangedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, Account account, Timestamp when) {
+
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
+      for (PrivateStateChangedListener l : listeners) {
+        try {
+          l.onPrivateStateChanged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(event, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent
+      implements PrivateStateChangedListener.Event {
+
+    protected Event(ChangeInfo change, AccountInfo who, Timestamp when) {
+      super(change, who, when, NotifyHandling.ALL);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
new file mode 100644
index 0000000..7290b27
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class WorkInProgressStateChanged {
+  private static final Logger log = LoggerFactory.getLogger(WorkInProgressStateChanged.class);
+
+  private final DynamicSet<WorkInProgressStateChangedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  WorkInProgressStateChanged(
+      DynamicSet<WorkInProgressStateChangedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, Account account, Timestamp when) {
+
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(util.changeInfo(change), util.accountInfo(account), when);
+      for (WorkInProgressStateChangedListener l : listeners) {
+        try {
+          l.onWorkInProgressStateChanged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(event, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent
+      implements WorkInProgressStateChangedListener.Event {
+
+    protected Event(ChangeInfo change, AccountInfo who, Timestamp when) {
+      super(change, who, when, NotifyHandling.ALL);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index dcbbea8..77c5b8e 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -2932,16 +2932,17 @@
           accountsUpdate
               .create()
               .update(
+                  "Set Full Name on Receive Commits",
                   user.getAccountId(),
-                  a -> {
+                  (a, u) -> {
                     if (Strings.isNullOrEmpty(a.getFullName())) {
-                      a.setFullName(setFullNameTo);
+                      u.setFullName(setFullNameTo);
                     }
                   });
       if (account != null) {
         user.getAccount().setFullName(account.getFullName());
       }
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (OrmException | IOException | ConfigInvalidException e) {
       logWarn("Failed to update full name of caller", e);
     }
   }
@@ -2950,7 +2951,11 @@
       throws OrmException {
     Map<Change.Key, ChangeNotes> r = new HashMap<>();
     for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
-      r.put(cd.change().getKey(), cd.notes());
+      try {
+        r.put(cd.change().getKey(), cd.notes());
+      } catch (NoSuchChangeException e) {
+        //Ignore deleted change
+      }
     }
     return r;
   }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 9a29193..fc280c2 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -718,7 +718,7 @@
             throw new CommitValidationException("invalid external IDs", msgs);
           }
           return msgs;
-        } catch (IOException e) {
+        } catch (IOException | ConfigInvalidException e) {
           String m = "error validating external IDs";
           log.warn(m, e);
           throw new CommitValidationException(m, e);
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index a7b3441..ef2c9b3 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -147,7 +147,10 @@
       throw new OrmException("NoteDb is required to read change " + changeId);
     }
     boolean readOrWrite = read || args.migration.rawWriteChangesSetting();
-    if (!readOrWrite && !autoRebuild) {
+    if (!readOrWrite) {
+      // Don't even open the repo if we neither write to nor read from NoteDb. It's possible that
+      // there is some garbage in the noteDbState field and/or the repo, but at this point NoteDb is
+      // completely off so it's none of our business.
       loadDefaults();
       return self();
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 7799d2d..66ff57c 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -737,6 +737,9 @@
         if (state == null) {
           return super.openHandle(repo, id);
         } else if (shouldExist) {
+          // TODO(dborowitz): This means we have a state recorded in noteDbState but the ref doesn't
+          // exist for whatever reason. Doesn't this mean we should trigger an auto-rebuild, rather
+          // than throwing?
           throw new NoSuchChangeException(getChangeId());
         }
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 153c9c3..35e4a12 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -80,7 +80,9 @@
     } else {
       pushCert = null;
     }
-    return noteUtil.parseNote(raw, p, changeId);
+    List<Comment> comments = noteUtil.parseNote(raw, p, changeId);
+    comments.forEach(c -> c.legacyFormat = true);
+    return comments;
   }
 
   private static boolean isJson(byte[] raw, int offset) {
diff --git a/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java b/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
index f919efb..e33ece9 100644
--- a/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
+++ b/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
@@ -265,7 +265,7 @@
     // the primary storage to NoteDb.
 
     setPrimaryStorageNoteDb(id, rebuiltState);
-    log.info("Migrated change {} to NoteDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
+    log.debug("Migrated change {} to NoteDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
   }
 
   private Change setReadOnlyInReviewDb(Change.Id id) throws OrmException {
@@ -399,7 +399,7 @@
     rebuilder.rebuildReviewDb(db(), project, id);
     setPrimaryStorageReviewDb(id, newMetaId);
     releaseReadOnlyLeaseInNoteDb(project, id);
-    log.info("Migrated change {} to ReviewDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
+    log.debug("Migrated change {} to ReviewDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
   }
 
   private ObjectId setReadOnlyInNoteDb(Project.NameKey project, Change.Id id)
diff --git a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
index d5031dc8..2d1b076 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.notedb.rebuild;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
@@ -228,9 +229,16 @@
       throw new NoSuchChangeException(changeId);
     }
 
-    final String oldNoteDbState = change.getNoteDbState();
+    String oldNoteDbStateStr = change.getNoteDbState();
     Result r = manager.stageAndApplyDelta(change);
-    final String newNoteDbState = change.getNoteDbState();
+    String newNoteDbStateStr = change.getNoteDbState();
+    if (newNoteDbStateStr == null) {
+      throw new OrmException(
+          "Rebuilding change %s produced no writes to NoteDb: "
+              + bundleReader.fromReviewDb(db, changeId));
+    }
+    NoteDbChangeState newNoteDbState =
+        checkNotNull(NoteDbChangeState.parse(changeId, newNoteDbStateStr));
     try {
       db.changes()
           .atomicUpdate(
@@ -241,15 +249,15 @@
                   if (checkReadOnly) {
                     NoteDbChangeState.checkNotReadOnly(change, skewMs);
                   }
-                  String currNoteDbState = change.getNoteDbState();
-                  if (Objects.equals(currNoteDbState, newNoteDbState)) {
+                  String currNoteDbStateStr = change.getNoteDbState();
+                  if (Objects.equals(currNoteDbStateStr, newNoteDbStateStr)) {
                     // Another thread completed the same rebuild we were about to.
                     throw new AbortUpdateException();
-                  } else if (!Objects.equals(oldNoteDbState, currNoteDbState)) {
+                  } else if (!Objects.equals(oldNoteDbStateStr, currNoteDbStateStr)) {
                     // Another thread updated the state to something else.
-                    throw new ConflictingUpdateRuntimeException(change, oldNoteDbState);
+                    throw new ConflictingUpdateRuntimeException(change, oldNoteDbStateStr);
                   }
-                  change.setNoteDbState(newNoteDbState);
+                  change.setNoteDbState(newNoteDbStateStr);
                   return change;
                 }
               });
@@ -259,10 +267,9 @@
       // rebuild had executed before the other thread.
       throw new ConflictingUpdateException(e);
     } catch (AbortUpdateException e) {
-      if (NoteDbChangeState.parse(changeId, newNoteDbState)
-          .isUpToDate(
-              manager.getChangeRepo().cmds.getRepoRefCache(),
-              manager.getAllUsersRepo().cmds.getRepoRefCache())) {
+      if (newNoteDbState.isUpToDate(
+          manager.getChangeRepo().cmds.getRepoRefCache(),
+          manager.getAllUsersRepo().cmds.getRepoRefCache())) {
         // If the state in ReviewDb matches NoteDb at this point, it means another thread
         // successfully completed this rebuild. It's ok to not execute the update in this case,
         // since the object referenced in the Result was flushed to the repo by whatever thread won
diff --git a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
index 2981174..c8d69b9 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
@@ -91,6 +91,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.internal.storage.file.PackInserter;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -748,9 +749,12 @@
   }
 
   private static ObjectInserter newPackInserter(Repository repo) {
-    return repo instanceof FileRepository
-        ? ((FileRepository) repo).getObjectDatabase().newPackInserter()
-        : repo.newObjectInserter();
+    if (!(repo instanceof FileRepository)) {
+      return repo.newObjectInserter();
+    }
+    PackInserter ins = ((FileRepository) repo).getObjectDatabase().newPackInserter();
+    ins.checkExisting(false);
+    return ins;
   }
 
   private boolean rebuildProject(
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
new file mode 100644
index 0000000..e0262bb
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -0,0 +1,22 @@
+package(
+    default_visibility = ["//visibility:public"],
+)
+
+java_library(
+    name = "restapi",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/org/eclipse/jgit:server",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
diff --git a/java/com/google/gerrit/server/config/CachesCollection.java b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
similarity index 95%
rename from java/com/google/gerrit/server/config/CachesCollection.java
rename to java/com/google/gerrit/server/restapi/config/CachesCollection.java
index 7ecfa63..cfdc648 100644
--- a/java/com/google/gerrit/server/config/CachesCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/CachesCollection.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
@@ -28,6 +28,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.CacheResource;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/config/CapabilitiesCollection.java b/java/com/google/gerrit/server/restapi/config/CapabilitiesCollection.java
similarity index 91%
rename from java/com/google/gerrit/server/config/CapabilitiesCollection.java
rename to java/com/google/gerrit/server/restapi/config/CapabilitiesCollection.java
index 1124048..ae1278d 100644
--- a/java/com/google/gerrit/server/config/CapabilitiesCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/CapabilitiesCollection.java
@@ -12,13 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.config.CapabilityResource;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
diff --git a/java/com/google/gerrit/server/config/CheckConsistency.java b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
similarity index 97%
rename from java/com/google/gerrit/server/config/CheckConsistency.java
rename to java/com/google/gerrit/server/restapi/config/CheckConsistency.java
index 38813de..95b20c2 100644
--- a/java/com/google/gerrit/server/config/CheckConsistency.java
+++ b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountExternalIdsResultInfo;
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountsConsistencyChecker;
 import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.group.db.GroupsConsistencyChecker;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
diff --git a/java/com/google/gerrit/server/config/ConfigCollection.java b/java/com/google/gerrit/server/restapi/config/ConfigCollection.java
similarity index 94%
rename from java/com/google/gerrit/server/config/ConfigCollection.java
rename to java/com/google/gerrit/server/restapi/config/ConfigCollection.java
index f268110..934dbc1 100644
--- a/java/com/google/gerrit/server/config/ConfigCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigCollection.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
diff --git a/java/com/google/gerrit/server/config/Module.java b/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
similarity index 83%
rename from java/com/google/gerrit/server/config/Module.java
rename to java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
index 7bf5ad5..71b2f9c 100644
--- a/java/com/google/gerrit/server/config/Module.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestModule.java
@@ -12,23 +12,23 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
-import static com.google.gerrit.server.config.CapabilityResource.CAPABILITY_KIND;
 import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
 import static com.google.gerrit.server.config.TaskResource.TASK_KIND;
-import static com.google.gerrit.server.config.TopMenuResource.TOP_MENU_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.config.CapabilityResource;
+import com.google.gerrit.server.config.TopMenuResource;
 
-public class Module extends RestApiModule {
+public class ConfigRestModule extends RestApiModule {
   @Override
   protected void configure() {
-    DynamicMap.mapOf(binder(), CAPABILITY_KIND);
+    DynamicMap.mapOf(binder(), CapabilityResource.CAPABILITY_KIND);
     DynamicMap.mapOf(binder(), CONFIG_KIND);
     DynamicMap.mapOf(binder(), TASK_KIND);
-    DynamicMap.mapOf(binder(), TOP_MENU_KIND);
+    DynamicMap.mapOf(binder(), TopMenuResource.TOP_MENU_KIND);
     child(CONFIG_KIND, "capabilities").to(CapabilitiesCollection.class);
     child(CONFIG_KIND, "tasks").to(TasksCollection.class);
     get(TASK_KIND).to(GetTask.class);
diff --git a/java/com/google/gerrit/server/config/ConfirmEmail.java b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
similarity index 94%
rename from java/com/google/gerrit/server/config/ConfirmEmail.java
rename to java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
index 1044bbb..f6ceb68b 100644
--- a/java/com/google/gerrit/server/config/ConfirmEmail.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfirmEmail.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -23,8 +23,9 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.config.ConfirmEmail.Input;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.gerrit.server.restapi.config.ConfirmEmail.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/server/config/DeleteTask.java b/java/com/google/gerrit/server/restapi/config/DeleteTask.java
similarity index 93%
rename from java/com/google/gerrit/server/config/DeleteTask.java
rename to java/com/google/gerrit/server/restapi/config/DeleteTask.java
index d20589a..a08b036 100644
--- a/java/com/google/gerrit/server/config/DeleteTask.java
+++ b/java/com/google/gerrit/server/restapi/config/DeleteTask.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK;
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.config.TaskResource;
 import com.google.gerrit.server.git.WorkQueue.Task;
 import com.google.inject.Singleton;
 
diff --git a/java/com/google/gerrit/server/config/FlushCache.java b/java/com/google/gerrit/server/restapi/config/FlushCache.java
similarity index 95%
rename from java/com/google/gerrit/server/config/FlushCache.java
rename to java/com/google/gerrit/server/restapi/config/FlushCache.java
index 5d2ec36..55e9dc3 100644
--- a/java/com/google/gerrit/server/config/FlushCache.java
+++ b/java/com/google/gerrit/server/restapi/config/FlushCache.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.CacheResource;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/config/GetCache.java b/java/com/google/gerrit/server/restapi/config/GetCache.java
similarity index 77%
rename from java/com/google/gerrit/server/config/GetCache.java
rename to java/com/google/gerrit/server/restapi/config/GetCache.java
index 53628cc..5abaf1e 100644
--- a/java/com/google/gerrit/server/config/GetCache.java
+++ b/java/com/google/gerrit/server/restapi/config/GetCache.java
@@ -12,17 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.config.CacheResource;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetCache implements RestReadView<CacheResource> {
 
   @Override
-  public CacheInfo apply(CacheResource rsrc) {
-    return new CacheInfo(rsrc.getName(), rsrc.getCache());
+  public ListCaches.CacheInfo apply(CacheResource rsrc) {
+    return new ListCaches.CacheInfo(rsrc.getName(), rsrc.getCache());
   }
 }
diff --git a/java/com/google/gerrit/server/config/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
similarity index 94%
rename from java/com/google/gerrit/server/config/GetDiffPreferences.java
rename to java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
index 8393fb4..6e72503 100644
--- a/java/com/google/gerrit/server/config/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
@@ -11,7 +11,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 
@@ -20,6 +20,8 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UserConfigSections;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/config/GetPreferences.java b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
similarity index 94%
rename from java/com/google/gerrit/server/config/GetPreferences.java
rename to java/com/google/gerrit/server/restapi/config/GetPreferences.java
index ed212f4..c8a173f 100644
--- a/java/com/google/gerrit/server/config/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 
@@ -20,6 +20,8 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.GeneralPreferencesLoader;
 import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UserConfigSections;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
similarity index 95%
rename from java/com/google/gerrit/server/config/GetServerInfo.java
rename to java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 90e2838..c353c5b 100644
--- a/java/com/google/gerrit/server/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static java.util.stream.Collectors.toList;
 
@@ -48,6 +48,16 @@
 import com.google.gerrit.server.change.AllowedFormats;
 import com.google.gerrit.server.change.ArchiveFormat;
 import com.google.gerrit.server.change.Submit;
+import com.google.gerrit.server.config.AgreementJson;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritOptions;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -229,7 +239,6 @@
         toBoolean(
             cfg.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
     info.largeChange = cfg.getInt("change", "largeChange", 500);
-    info.privateByDefault = toBoolean(cfg.getBoolean("change", "privateByDefault", false));
     info.replyTooltip =
         Optional.ofNullable(cfg.getString("change", null, "replyTooltip")).orElse("Reply and score")
             + " (Shortcut: a)";
diff --git a/java/com/google/gerrit/server/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
similarity index 97%
rename from java/com/google/gerrit/server/config/GetSummary.java
rename to java/com/google/gerrit/server/restapi/config/GetSummary.java
index 82912c0..26f069c 100644
--- a/java/com/google/gerrit/server/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -12,11 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.Task;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/config/GetTask.java b/java/com/google/gerrit/server/restapi/config/GetTask.java
similarity index 79%
rename from java/com/google/gerrit/server/config/GetTask.java
rename to java/com/google/gerrit/server/restapi/config/GetTask.java
index e4b3320..a32f3ba 100644
--- a/java/com/google/gerrit/server/config/GetTask.java
+++ b/java/com/google/gerrit/server/restapi/config/GetTask.java
@@ -12,17 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
+import com.google.gerrit.server.config.TaskResource;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetTask implements RestReadView<TaskResource> {
 
   @Override
-  public TaskInfo apply(TaskResource rsrc) {
-    return new TaskInfo(rsrc.getTask());
+  public ListTasks.TaskInfo apply(TaskResource rsrc) {
+    return new ListTasks.TaskInfo(rsrc.getTask());
   }
 }
diff --git a/java/com/google/gerrit/server/config/GetVersion.java b/java/com/google/gerrit/server/restapi/config/GetVersion.java
similarity index 91%
rename from java/com/google/gerrit/server/config/GetVersion.java
rename to java/com/google/gerrit/server/restapi/config/GetVersion.java
index c71cb69..8135719 100644
--- a/java/com/google/gerrit/server/config/GetVersion.java
+++ b/java/com/google/gerrit/server/restapi/config/GetVersion.java
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Singleton;
 
 @Singleton
diff --git a/java/com/google/gerrit/server/config/ListCaches.java b/java/com/google/gerrit/server/restapi/config/ListCaches.java
similarity index 97%
rename from java/com/google/gerrit/server/config/ListCaches.java
rename to java/com/google/gerrit/server/restapi/config/ListCaches.java
index d78f61d..c0a9d71 100644
--- a/java/com/google/gerrit/server/config/ListCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCaches.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.cache.PersistentCache;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collections;
diff --git a/java/com/google/gerrit/server/config/ListCapabilities.java b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
similarity index 94%
rename from java/com/google/gerrit/server/config/ListCapabilities.java
rename to java/com/google/gerrit/server/restapi/config/ListCapabilities.java
index b8d1888..6a1e5f6 100644
--- a/java/com/google/gerrit/server/config/ListCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCapabilities.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.config.CapabilityConstants;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
similarity index 97%
rename from java/com/google/gerrit/server/config/ListTasks.java
rename to java/com/google/gerrit/server/restapi/config/ListTasks.java
index bbda9eb..71ee5ad 100644
--- a/java/com/google/gerrit/server/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.common.collect.ComparisonChain;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.TaskInfoFactory;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
diff --git a/java/com/google/gerrit/server/config/ListTopMenus.java b/java/com/google/gerrit/server/restapi/config/ListTopMenus.java
similarity index 92%
rename from java/com/google/gerrit/server/config/ListTopMenus.java
rename to java/com/google/gerrit/server/restapi/config/ListTopMenus.java
index a7ba938..7a85bcd 100644
--- a/java/com/google/gerrit/server/config/ListTopMenus.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTopMenus.java
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.webui.TopMenu;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/config/PostCaches.java b/java/com/google/gerrit/server/restapi/config/PostCaches.java
similarity index 94%
rename from java/com/google/gerrit/server/config/PostCaches.java
rename to java/com/google/gerrit/server/restapi/config/PostCaches.java
index d08f0a9..f21672c 100644
--- a/java/com/google/gerrit/server/config/PostCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/PostCaches.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
@@ -25,8 +25,10 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.config.PostCaches.Input;
+import com.google.gerrit.server.config.CacheResource;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.PostCaches.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/config/RestCacheAdminModule.java b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
similarity index 95%
rename from java/com/google/gerrit/server/config/RestCacheAdminModule.java
rename to java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
index 992c62e..7283033 100644
--- a/java/com/google/gerrit/server/config/RestCacheAdminModule.java
+++ b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.server.config.CacheResource.CACHE_KIND;
 import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
diff --git a/java/com/google/gerrit/server/config/SetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
similarity index 93%
rename from java/com/google/gerrit/server/config/SetDiffPreferences.java
rename to java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
index 80c4625..a61b2aa 100644
--- a/java/com/google/gerrit/server/config/SetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetDiffPreferences.java
@@ -11,12 +11,11 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 import static com.google.gerrit.server.config.ConfigUtil.skipField;
 import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-import static com.google.gerrit.server.config.GetDiffPreferences.readFromGit;
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -24,6 +23,8 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
@@ -65,7 +66,7 @@
     if (!hasSetFields(in)) {
       throw new BadRequestException("unsupported option");
     }
-    return writeToGit(readFromGit(gitManager, allUsersName, in));
+    return writeToGit(GetDiffPreferences.readFromGit(gitManager, allUsersName, in));
   }
 
   private DiffPreferencesInfo writeToGit(DiffPreferencesInfo in)
diff --git a/java/com/google/gerrit/server/config/SetPreferences.java b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
similarity index 94%
rename from java/com/google/gerrit/server/config/SetPreferences.java
rename to java/com/google/gerrit/server/restapi/config/SetPreferences.java
index 55337d5..e2671d1 100644
--- a/java/com/google/gerrit/server/config/SetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/SetPreferences.java
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import static com.google.gerrit.server.config.ConfigUtil.loadSection;
 import static com.google.gerrit.server.config.ConfigUtil.skipField;
 import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-import static com.google.gerrit.server.config.GetPreferences.readFromGit;
+import static com.google.gerrit.server.restapi.config.GetPreferences.readFromGit;
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -27,6 +27,8 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GeneralPreferencesLoader;
 import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
diff --git a/java/com/google/gerrit/server/config/TasksCollection.java b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
similarity index 95%
rename from java/com/google/gerrit/server/config/TasksCollection.java
rename to java/com/google/gerrit/server/restapi/config/TasksCollection.java
index fcaee8e..f5b6e56 100644
--- a/java/com/google/gerrit/server/config/TasksCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -21,6 +21,8 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.TaskResource;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
 import com.google.gerrit.server.git.WorkQueue.Task;
diff --git a/java/com/google/gerrit/server/config/TopMenuCollection.java b/java/com/google/gerrit/server/restapi/config/TopMenuCollection.java
similarity index 91%
rename from java/com/google/gerrit/server/config/TopMenuCollection.java
rename to java/com/google/gerrit/server/restapi/config/TopMenuCollection.java
index 2fc2dc1..36a1b04 100644
--- a/java/com/google/gerrit/server/config/TopMenuCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/TopMenuCollection.java
@@ -12,13 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.config;
+package com.google.gerrit.server.restapi.config;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.TopMenuResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
diff --git a/java/com/google/gerrit/server/schema/Schema_144.java b/java/com/google/gerrit/server/schema/Schema_144.java
index d43b887..98dcd39 100644
--- a/java/com/google/gerrit/server/schema/Schema_144.java
+++ b/java/com/google/gerrit/server/schema/Schema_144.java
@@ -18,11 +18,11 @@
 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.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -34,12 +34,8 @@
 import java.util.HashSet;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 public class Schema_144 extends SchemaVersion {
   private static final String COMMIT_MSG = "Import external IDs from ReviewDb";
@@ -83,29 +79,16 @@
     }
 
     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);
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+        extIdNotes.upsert(toAdd);
+        try (MetaDataUpdate metaDataUpdate =
+            new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo)) {
+          metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
+          metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
+          metaDataUpdate.getCommitBuilder().setMessage(COMMIT_MSG);
+          extIdNotes.commit(metaDataUpdate);
         }
-
-        ExternalIdsUpdate.commit(
-            allUsersName,
-            repo,
-            rw,
-            ins,
-            rev,
-            noteMap,
-            COMMIT_MSG,
-            serverIdent,
-            serverIdent,
-            null,
-            GitReferenceUpdated.DISABLED);
       }
     } catch (IOException | ConfigInvalidException e) {
       throw new OrmException("Failed to migrate external IDs to NoteDb", e);
diff --git a/java/com/google/gerrit/server/schema/Schema_148.java b/java/com/google/gerrit/server/schema/Schema_148.java
index 47751cd..0c22964 100644
--- a/java/com/google/gerrit/server/schema/Schema_148.java
+++ b/java/com/google/gerrit/server/schema/Schema_148.java
@@ -14,17 +14,15 @@
 
 package com.google.gerrit.server.schema;
 
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
 import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdReader;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -32,13 +30,8 @@
 import java.sql.SQLException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 public class Schema_148 extends SchemaVersion {
   private static final String COMMIT_MSG = "Make account IDs of external IDs human-readable";
@@ -61,44 +54,22 @@
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId rev = ExternalIdReader.readRevision(repo);
-      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-      boolean dirty = false;
-      for (Note note : noteMap) {
-        byte[] raw =
-            rw.getObjectReader()
-                .open(note.getData(), OBJ_BLOB)
-                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
-        try {
-          ExternalId extId = ExternalId.parse(note.getName(), raw, note.getData());
-
-          if (needsUpdate(extId)) {
-            ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
-            dirty = true;
-          }
-        } catch (ConfigInvalidException e) {
-          ui.message(
-              String.format("Warning: Ignoring invalid external ID note %s", note.getName()));
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      for (ExternalId extId : extIdNotes.all()) {
+        if (needsUpdate(extId)) {
+          extIdNotes.upsert(extId);
         }
       }
-      if (dirty) {
-        ExternalIdsUpdate.commit(
-            allUsersName,
-            repo,
-            rw,
-            ins,
-            rev,
-            noteMap,
-            COMMIT_MSG,
-            serverUser,
-            serverUser,
-            null,
-            GitReferenceUpdated.DISABLED);
+
+      try (MetaDataUpdate metaDataUpdate =
+          new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo)) {
+        metaDataUpdate.getCommitBuilder().setAuthor(serverUser);
+        metaDataUpdate.getCommitBuilder().setCommitter(serverUser);
+        metaDataUpdate.getCommitBuilder().setMessage(COMMIT_MSG);
+        extIdNotes.commit(metaDataUpdate);
       }
-    } catch (IOException e) {
+    } catch (IOException | ConfigInvalidException e) {
       throw new OrmException("Failed to update external IDs", e);
     }
   }
diff --git a/java/com/google/gerrit/server/schema/Schema_154.java b/java/com/google/gerrit/server/schema/Schema_154.java
index 5a4ba13..8e05d38 100644
--- a/java/com/google/gerrit/server/schema/Schema_154.java
+++ b/java/com/google/gerrit/server/schema/Schema_154.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.schema;
 
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -22,23 +25,40 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
+import java.sql.Connection;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
+import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** Migrate accounts to NoteDb. */
 public class Schema_154 extends SchemaVersion {
+  private static final Logger log = LoggerFactory.getLogger(Schema_154.class);
+  private static final String TABLE = "accounts";
+  private static final ImmutableMap<String, AccountSetter> ACCOUNT_FIELDS_MAP =
+      ImmutableMap.<String, AccountSetter>builder()
+          .put("full_name", (a, rs, field) -> a.setFullName(rs.getString(field)))
+          .put("preferred_email", (a, rs, field) -> a.setPreferredEmail(rs.getString(field)))
+          .put("status", (a, rs, field) -> a.setStatus(rs.getString(field)))
+          .put("inactive", (a, rs, field) -> a.setActive(rs.getString(field).equals("N")))
+          .build();
+
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final Provider<PersonIdent> serverIdent;
@@ -76,23 +96,24 @@
   }
 
   private Set<Account> scanAccounts(ReviewDb db, ProgressMonitor pm) throws SQLException {
+    Map<String, AccountSetter> fields = getFields(db);
+    if (fields.isEmpty()) {
+      log.warn("Only account_id and registered_on fields are migrated for accounts");
+    }
+
+    List<String> queryFields = new ArrayList<>();
+    queryFields.add("account_id");
+    queryFields.add("registered_on");
+    queryFields.addAll(fields.keySet());
+    String query = "SELECT " + String.join(", ", queryFields) + String.format(" FROM %s", TABLE);
     try (Statement stmt = newStatement(db);
-        ResultSet rs =
-            stmt.executeQuery(
-                "SELECT account_id,"
-                    + " registered_on,"
-                    + " full_name, "
-                    + " preferred_email,"
-                    + " status,"
-                    + " inactive"
-                    + " FROM accounts")) {
+        ResultSet rs = stmt.executeQuery(query)) {
       Set<Account> s = new HashSet<>();
       while (rs.next()) {
         Account a = new Account(new Account.Id(rs.getInt(1)), rs.getTimestamp(2));
-        a.setFullName(rs.getString(3));
-        a.setPreferredEmail(rs.getString(4));
-        a.setStatus(rs.getString(5));
-        a.setActive(rs.getString(6).equals("N"));
+        for (Map.Entry<String, AccountSetter> field : fields.entrySet()) {
+          field.getValue().set(a, rs, field.getKey());
+        }
         s.add(a);
         pm.update(1);
       }
@@ -100,6 +121,17 @@
     }
   }
 
+  private Map<String, AccountSetter> getFields(ReviewDb db) throws SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    Connection connection = schema.getConnection();
+    Set<String> columns = schema.getDialect().listColumns(connection, TABLE);
+    return ACCOUNT_FIELDS_MAP
+        .entrySet()
+        .stream()
+        .filter(e -> columns.contains(e.getKey()))
+        .collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
+  }
+
   private void updateAccountInNoteDb(Repository allUsersRepo, Account account)
       throws IOException, ConfigInvalidException {
     MetaDataUpdate md =
@@ -112,4 +144,9 @@
     accountConfig.setAccount(account);
     accountConfig.commit(md);
   }
+
+  @FunctionalInterface
+  private interface AccountSetter {
+    void set(Account a, ResultSet rs, String field) throws SQLException;
+  }
 }
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index a33ce86..3ed1f2f 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -14,6 +14,7 @@
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
+        "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/util/cli",
         "//java/org/eclipse/jgit:server",
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 392fd29..2271ece 100644
--- a/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -16,17 +16,17 @@
 
 import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH;
-import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH_ALL;
+import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH;
+import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH_ALL;
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.ListCaches;
-import com.google.gerrit.server.config.ListCaches.OutputFormat;
-import com.google.gerrit.server.config.PostCaches;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.ListCaches;
+import com.google.gerrit.server.restapi.config.ListCaches.OutputFormat;
+import com.google.gerrit.server.restapi.config.PostCaches;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/sshd/commands/KillCommand.java b/java/com/google/gerrit/sshd/commands/KillCommand.java
index 3465a9c..a7e751a 100644
--- a/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -22,10 +22,10 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.DeleteTask;
 import com.google.gerrit.server.config.TaskResource;
-import com.google.gerrit.server.config.TasksCollection;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.DeleteTask;
+import com.google.gerrit.server.restapi.config.TasksCollection;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 1ed7db3..a356f7f 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -26,18 +26,18 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.GetSummary;
-import com.google.gerrit.server.config.GetSummary.JvmSummaryInfo;
-import com.google.gerrit.server.config.GetSummary.MemSummaryInfo;
-import com.google.gerrit.server.config.GetSummary.SummaryInfo;
-import com.google.gerrit.server.config.GetSummary.TaskSummaryInfo;
-import com.google.gerrit.server.config.GetSummary.ThreadSummaryInfo;
-import com.google.gerrit.server.config.ListCaches;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.config.ListCaches.CacheType;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.GetSummary;
+import com.google.gerrit.server.restapi.config.GetSummary.JvmSummaryInfo;
+import com.google.gerrit.server.restapi.config.GetSummary.MemSummaryInfo;
+import com.google.gerrit.server.restapi.config.GetSummary.SummaryInfo;
+import com.google.gerrit.server.restapi.config.GetSummary.TaskSummaryInfo;
+import com.google.gerrit.server.restapi.config.GetSummary.ThreadSummaryInfo;
+import com.google.gerrit.server.restapi.config.ListCaches;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 0296690..6d2fbb4 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -23,13 +23,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.ConfigResource;
-import com.google.gerrit.server.config.ListTasks;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.Task;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.restapi.config.ListTasks;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 2715c75..59102a9 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -25,6 +25,7 @@
         "//java/com/google/gerrit/server:module",
         "//java/com/google/gerrit/server/api",
         "//java/com/google/gerrit/server/cache/h2",
+        "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//lib:gwtorm",
         "//lib:h2",
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 25e02c0..4b34ade 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -31,10 +31,12 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import com.github.rholder.retry.StopStrategies;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
@@ -54,6 +56,7 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -64,6 +67,7 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
@@ -75,6 +79,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.gpg.Fingerprint;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.testing.TestKey;
@@ -90,18 +95,25 @@
 import com.google.gerrit.server.account.WatchConfig;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.name.Named;
@@ -119,10 +131,12 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -163,10 +177,6 @@
 
   @Inject private ExternalIds externalIds;
 
-  @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
-
-  @Inject private ExternalIdsUpdate.ServerNoReindex externalIdsUpdateNoReindexFactory;
-
   @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
 
   @Inject private DynamicSet<GitReferenceUpdatedListener> refUpdateListeners;
@@ -181,6 +191,16 @@
 
   @Inject private AccountIndexer accountIndexer;
 
+  @Inject private OutgoingEmailValidator emailValidator;
+
+  @Inject private GitReferenceUpdated gitReferenceUpdated;
+
+  @Inject private RetryHelper.Metrics retryMetrics;
+
+  @Inject private Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+
+  @Inject private ExternalIdNotes.Factory extIdNotesFactory;
+
   @Inject
   @Named("accounts")
   private LoadingCache<Account.Id, Optional<AccountState>> accountsCache;
@@ -189,8 +209,6 @@
   private RegistrationHandle accountIndexEventCounterHandle;
   private RefUpdateCounter refUpdateCounter;
   private RegistrationHandle refUpdateCounterHandle;
-  private ExternalIdsUpdate externalIdsUpdate;
-  private List<ExternalId> savedExternalIds;
 
   @Before
   public void addAccountIndexEventCounter() {
@@ -218,27 +236,6 @@
     }
   }
 
-  @Before
-  public void saveExternalIds() throws Exception {
-    externalIdsUpdate = externalIdsUpdateFactory.create();
-
-    savedExternalIds = new ArrayList<>();
-    savedExternalIds.addAll(externalIds.byAccount(admin.id));
-    savedExternalIds.addAll(externalIds.byAccount(user.id));
-  }
-
-  @After
-  public void restoreExternalIds() throws Exception {
-    if (savedExternalIds != null) {
-      // savedExternalIds is null when we don't run SSH tests and the assume in
-      // @Before in AbstractDaemonTest prevents this class' @Before method from
-      // being executed.
-      externalIdsUpdate.delete(externalIds.byAccount(admin.id));
-      externalIdsUpdate.delete(externalIds.byAccount(user.id));
-      externalIdsUpdate.insert(savedExternalIds);
-    }
-  }
-
   @After
   public void clearPublicKeyStore() throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
@@ -266,8 +263,8 @@
   }
 
   @Test
-  public void create() throws Exception {
-    Account.Id accountId = create(2); // account creation + external ID creation
+  public void createByAccountCreator() throws Exception {
+    Account.Id accountId = createByAccountCreator(2); // account creation + external ID creation
     refUpdateCounter.assertRefUpdateFor(
         RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
         RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
@@ -276,8 +273,9 @@
 
   @Test
   @UseSsh
-  public void createWithSshKeys() throws Exception {
-    Account.Id accountId = create(3); // account creation + external ID creation + adding SSH keys
+  public void createWithSshKeysByAccountCreator() throws Exception {
+    Account.Id accountId =
+        createByAccountCreator(3); // account creation + external ID creation + adding SSH keys
     refUpdateCounter.assertRefUpdateFor(
         ImmutableMap.of(
             RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
@@ -289,7 +287,7 @@
             1));
   }
 
-  private Account.Id create(int expectedAccountReindexCalls) throws Exception {
+  private Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
     String name = "foo";
     TestAccount foo = accountCreator.create(name);
     AccountInfo info = gApi.accounts().id(foo.id.get()).get();
@@ -301,18 +299,115 @@
   }
 
   @Test
-  public void createAnonymousCoward() throws Exception {
+  public void createAnonymousCowardByAccountCreator() throws Exception {
     TestAccount anonymousCoward = accountCreator.create();
     accountIndexedCounter.assertReindexOf(anonymousCoward);
     assertUserBranchWithoutAccountConfig(anonymousCoward.getId());
   }
 
   @Test
+  public void create() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = "foo";
+    input.name = "Foo";
+    input.email = "foo@example.com";
+    AccountInfo accountInfo = gApi.accounts().create(input).get();
+    assertThat(accountInfo._accountId).isNotNull();
+    assertThat(accountInfo.username).isEqualTo(input.username);
+    assertThat(accountInfo.name).isEqualTo(input.name);
+    assertThat(accountInfo.email).isEqualTo(input.email);
+    assertThat(accountInfo.status).isNull();
+
+    Account.Id accountId = new Account.Id(accountInfo._accountId);
+    accountIndexedCounter.assertReindexOf(accountId, 2); // account creation + external ID creation
+    assertThat(externalIds.byAccount(accountId))
+        .containsExactly(
+            ExternalId.createUsername(input.username, accountId, null),
+            ExternalId.createEmail(accountId, input.email));
+  }
+
+  @Test
+  public void createAccountUsernameAlreadyTaken() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = admin.username;
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("username '" + admin.username + "' already exists");
+    gApi.accounts().create(input);
+  }
+
+  @Test
+  public void createAccountEmailAlreadyTaken() throws Exception {
+    AccountInput input = new AccountInput();
+    input.username = "foo";
+    input.email = admin.email;
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("email '" + admin.email + "' already exists");
+    gApi.accounts().create(input);
+  }
+
+  @Test
+  public void commitMessageOnAccountUpdates() throws Exception {
+    AccountsUpdate au = accountsUpdate.create();
+    Account.Id accountId = new Account.Id(seq.nextAccountId());
+    au.insert("Create Test Account", accountId, u -> {});
+    assertLastCommitMessageOfUserBranch(accountId, "Create Test Account");
+
+    au.update("Set Status", accountId, u -> u.setStatus("Foo"));
+    assertLastCommitMessageOfUserBranch(accountId, "Set Status");
+  }
+
+  private void assertLastCommitMessageOfUserBranch(Account.Id accountId, String expectedMessage)
+      throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref exactRef = repo.exactRef(RefNames.refsUsers(accountId));
+      assertThat(rw.parseCommit(exactRef.getObjectId()).getShortMessage())
+          .isEqualTo(expectedMessage);
+    }
+  }
+
+  @Test
+  public void createAtomically() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, SECONDS);
+    try {
+      Account.Id accountId = new Account.Id(seq.nextAccountId());
+      String fullName = "Foo";
+      ExternalId extId = ExternalId.createEmail(accountId, "foo@example.com");
+      Account account =
+          accountsUpdate
+              .create()
+              .insert(
+                  "Create Account Atomically",
+                  accountId,
+                  u -> u.setFullName(fullName).addExternalId(extId));
+      assertThat(account.getFullName()).isEqualTo(fullName);
+
+      AccountInfo info = gApi.accounts().id(accountId.get()).get();
+      assertThat(info.name).isEqualTo(fullName);
+
+      List<EmailInfo> emails = gApi.accounts().id(accountId.get()).getEmails();
+      assertThat(emails.stream().map(e -> e.email).collect(toSet())).containsExactly(extId.email());
+
+      RevCommit commitUserBranch = getRemoteHead(allUsers, RefNames.refsUsers(accountId));
+      RevCommit commitRefsMetaExternalIds = getRemoteHead(allUsers, RefNames.REFS_EXTERNAL_IDS);
+      assertThat(commitUserBranch.getCommitTime())
+          .isEqualTo(commitRefsMetaExternalIds.getCommitTime());
+    } finally {
+      TestTimeUtil.useSystemTime();
+    }
+  }
+
+  @Test
   public void updateNonExistingAccount() throws Exception {
     Account.Id nonExistingAccountId = new Account.Id(999999);
     AtomicBoolean consumerCalled = new AtomicBoolean();
     Account account =
-        accountsUpdate.create().update(nonExistingAccountId, a -> consumerCalled.set(true));
+        accountsUpdate
+            .create()
+            .update(
+                "Update Non-Existing Account", nonExistingAccountId, a -> consumerCalled.set(true));
     assertThat(account).isNull();
     assertThat(consumerCalled.get()).isFalse();
   }
@@ -324,7 +419,9 @@
 
     String status = "OOO";
     Account account =
-        accountsUpdate.create().update(anonymousCoward.getId(), a -> a.setStatus(status));
+        accountsUpdate
+            .create()
+            .update("Set status", anonymousCoward.getId(), u -> u.setStatus(status));
     assertThat(account).isNotNull();
     assertThat(account.getFullName()).isNull();
     assertThat(account.getStatus()).isEqualTo(status);
@@ -771,11 +868,16 @@
     String email = "foo.bar@example.com";
     String extId1 = "foo:bar";
     String extId2 = "foo:baz";
-    List<ExternalId> extIds =
-        ImmutableList.of(
-            ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id, email),
-            ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email));
-    externalIdsUpdateFactory.create().insert(extIds);
+    accountsUpdate
+        .create()
+        .update(
+            "Add External IDs",
+            admin.id,
+            u ->
+                u.addExternalId(
+                        ExternalId.createWithEmail(ExternalId.Key.parse(extId1), admin.id, email))
+                    .addExternalId(
+                        ExternalId.createWithEmail(ExternalId.Key.parse(extId2), admin.id, email)));
     accountIndexedCounter.assertReindexOf(admin);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
@@ -827,9 +929,14 @@
 
     // exact match with other scheme
     String email = "foo.bar@example.com";
-    externalIdsUpdateFactory
+    accountsUpdate
         .create()
-        .insert(ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email));
+        .update(
+            "Add Email",
+            admin.id,
+            u ->
+                u.addExternalId(
+                    ExternalId.createWithEmail(ExternalId.Key.parse("foo:bar"), admin.id, email)));
     assertEmail(emails.getAccountFor(email), admin);
 
     // wrong case doesn't match
@@ -854,7 +961,9 @@
     String prefix = "foo.preferred";
     String prefEmail = prefix + "@example.com";
     TestAccount foo = accountCreator.create(name("foo"));
-    accountsUpdate.create().update(foo.id, a -> a.setPreferredEmail(prefEmail));
+    accountsUpdate
+        .create()
+        .update("Set Preferred Email", foo.id, u -> u.setPreferredEmail(prefEmail));
 
     // verify that the account is still found when using the preferred email to lookup the account
     ImmutableSet<Account.Id> accountsByPrefEmail = emails.getAccountFor(prefEmail);
@@ -1330,7 +1439,9 @@
     String userRef = RefNames.refsUsers(foo.id);
 
     String noEmail = "no.email";
-    accountsUpdate.create().update(foo.id, a -> a.setPreferredEmail(noEmail));
+    accountsUpdate
+        .create()
+        .update("Set Preferred Email", foo.id, u -> u.setPreferredEmail(noEmail));
     accountIndexedCounter.clear();
 
     grant(allUsers, userRef, Permission.PUSH, false, REGISTERED_USERS);
@@ -1591,7 +1702,7 @@
     assertIteratorSize(2, getOnlyKeyFromStore(key).getUserIDs());
 
     pk = PGPPublicKey.removeCertification(pk, "foo:myId");
-    info = addGpgKey(armor(pk)).get(id);
+    info = addGpgKeyNoReindex(armor(pk)).get(id);
     assertThat(info.userIds).hasSize(1);
     assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
   }
@@ -1600,7 +1711,12 @@
   public void addOtherUsersGpgKey_Conflict() throws Exception {
     // Both users have a matching external ID for this key.
     addExternalIdEmail(admin, "test5@example.com");
-    externalIdsUpdate.insert(ExternalId.create("foo", "myId", user.getId()));
+    accountsUpdate
+        .create()
+        .update(
+            "Add External ID",
+            user.getId(),
+            u -> u.addExternalId(ExternalId.create("foo", "myId", user.getId())));
     accountIndexedCounter.assertReindexOf(user);
 
     TestKey key = validKeyWithSecondUserId();
@@ -1774,7 +1890,12 @@
 
     // Delete the external ID for the preferred email. This makes the account inconsistent since it
     // now doesn't have an external ID for its preferred email.
-    externalIdsUpdate.delete(ExternalId.createEmail(account.getId(), email));
+    accountsUpdate
+        .create()
+        .update(
+            "Delete External ID",
+            account.getId(),
+            u -> u.deleteExternalId(ExternalId.createEmail(account.getId(), email)));
     expectedProblems.add(
         new ConsistencyProblemInfo(
             ConsistencyProblemInfo.Status.ERROR,
@@ -1812,11 +1933,11 @@
     // metaId is set when account is created
     AccountsUpdate au = accountsUpdate.create();
     Account.Id accountId = new Account.Id(seq.nextAccountId());
-    Account account = au.insert(accountId, a -> {});
+    Account account = au.insert("Create Test Account", accountId, u -> {});
     assertThat(account.getMetaId()).isEqualTo(getMetaId(accountId));
 
     // metaId is set when account is updated
-    Account updatedAccount = au.update(accountId, a -> a.setFullName("foo"));
+    Account updatedAccount = au.update("Set Full Name", accountId, u -> u.setFullName("foo"));
     assertThat(account.getMetaId()).isNotEqualTo(updatedAccount.getMetaId());
     assertThat(updatedAccount.getMetaId()).isEqualTo(getMetaId(accountId));
   }
@@ -1881,6 +2002,115 @@
   }
 
   @Test
+  public void retryOnLockFailure() throws Exception {
+    String status = "happy";
+    String fullName = "Foo";
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    PersonIdent ident = serverIdent.get();
+    AccountsUpdate update =
+        new AccountsUpdate(
+            repoManager,
+            gitReferenceUpdated,
+            null,
+            allUsers,
+            emailValidator,
+            metaDataUpdateInternalFactory,
+            new RetryHelper(
+                cfg,
+                retryMetrics,
+                null,
+                null,
+                null,
+                r -> r.withBlockStrategy(noSleepBlockStrategy)),
+            extIdNotesFactory,
+            ident,
+            ident,
+            () -> {
+              if (!doneBgUpdate.getAndSet(true)) {
+                try {
+                  accountsUpdate.create().update("Set Status", admin.id, u -> u.setStatus(status));
+                } catch (IOException | ConfigInvalidException | OrmException e) {
+                  // Ignore, the successful update of the account is asserted later
+                }
+              }
+            });
+    assertThat(doneBgUpdate.get()).isFalse();
+    AccountInfo accountInfo = gApi.accounts().id(admin.id.get()).get();
+    assertThat(accountInfo.status).isNull();
+    assertThat(accountInfo.name).isNotEqualTo(fullName);
+
+    Account updatedAccount = update.update("Set Full Name", admin.id, u -> u.setFullName(fullName));
+    assertThat(doneBgUpdate.get()).isTrue();
+
+    assertThat(updatedAccount.getStatus()).isEqualTo(status);
+    assertThat(updatedAccount.getFullName()).isEqualTo(fullName);
+
+    accountInfo = gApi.accounts().id(admin.id.get()).get();
+    assertThat(accountInfo.status).isEqualTo(status);
+    assertThat(accountInfo.name).isEqualTo(fullName);
+  }
+
+  @Test
+  public void failAfterRetryerGivesUp() throws Exception {
+    List<String> status = ImmutableList.of("foo", "bar", "baz");
+    String fullName = "Foo";
+    AtomicInteger bgCounter = new AtomicInteger(0);
+    PersonIdent ident = serverIdent.get();
+    AccountsUpdate update =
+        new AccountsUpdate(
+            repoManager,
+            gitReferenceUpdated,
+            null,
+            allUsers,
+            emailValidator,
+            metaDataUpdateInternalFactory,
+            new RetryHelper(
+                cfg,
+                retryMetrics,
+                null,
+                null,
+                null,
+                r ->
+                    r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
+                        .withBlockStrategy(noSleepBlockStrategy)),
+            extIdNotesFactory,
+            ident,
+            ident,
+            () -> {
+              try {
+                accountsUpdate
+                    .create()
+                    .update(
+                        "Set Status",
+                        admin.id,
+                        u -> u.setStatus(status.get(bgCounter.getAndAdd(1))));
+              } catch (IOException | ConfigInvalidException | OrmException e) {
+                // Ignore, the expected exception is asserted later
+              }
+            });
+    assertThat(bgCounter.get()).isEqualTo(0);
+    AccountInfo accountInfo = gApi.accounts().id(admin.id.get()).get();
+    assertThat(accountInfo.status).isNull();
+    assertThat(accountInfo.name).isNotEqualTo(fullName);
+
+    try {
+      update.update("Set Full Name", admin.id, u -> u.setFullName(fullName));
+      fail("expected LockFailureException");
+    } catch (LockFailureException e) {
+      // Ignore, expected
+    }
+    assertThat(bgCounter.get()).isEqualTo(status.size());
+
+    Account updatedAccount = accounts.get(admin.id);
+    assertThat(updatedAccount.getStatus()).isEqualTo(Iterables.getLast(status));
+    assertThat(updatedAccount.getFullName()).isEqualTo(admin.fullName);
+
+    accountInfo = gApi.accounts().id(admin.id.get()).get();
+    assertThat(accountInfo.status).isEqualTo(Iterables.getLast(status));
+    assertThat(accountInfo.name).isEqualTo(admin.fullName);
+  }
+
+  @Test
   public void stalenessChecker() throws Exception {
     // Newly created account is not stale.
     AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
@@ -1912,17 +2142,28 @@
 
     // Manually inserting/updating/deleting an external ID of the user makes the index document
     // stale.
-    ExternalIdsUpdate externalIdsUpdateNoReindex = externalIdsUpdateNoReindexFactory.create();
-    ExternalId.Key key = ExternalId.Key.create("foo", "foo");
-    externalIdsUpdateNoReindex.insert(ExternalId.create(key, accountId));
-    assertStaleAccountAndReindex(accountId);
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
 
-    externalIdsUpdateNoReindex.upsert(
-        ExternalId.createWithEmail(key, accountId, "foo@example.com"));
-    assertStaleAccountAndReindex(accountId);
+      ExternalId.Key key = ExternalId.Key.create("foo", "foo");
+      extIdNotes.insert(ExternalId.create(key, accountId));
+      try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+        extIdNotes.commit(update);
+      }
+      assertStaleAccountAndReindex(accountId);
 
-    externalIdsUpdateNoReindex.delete(accountId, key);
-    assertStaleAccountAndReindex(accountId);
+      extIdNotes.upsert(ExternalId.createWithEmail(key, accountId, "foo@example.com"));
+      try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+        extIdNotes.commit(update);
+      }
+      assertStaleAccountAndReindex(accountId);
+
+      extIdNotes.delete(accountId, key);
+      try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+        extIdNotes.commit(update);
+      }
+      assertStaleAccountAndReindex(accountId);
+    }
 
     // Manually delete account
     try (Repository repo = repoManager.openRepository(allUsers);
@@ -2047,8 +2288,14 @@
 
   private void addExternalIdEmail(TestAccount account, String email) throws Exception {
     checkNotNull(email);
-    externalIdsUpdate.insert(
-        ExternalId.createWithEmail(name("test"), email, account.getId(), email));
+    accountsUpdate
+        .create()
+        .update(
+            "Add Email",
+            account.getId(),
+            u ->
+                u.addExternalId(
+                    ExternalId.createWithEmail(name("test"), email, account.getId(), email)));
     accountIndexedCounter.assertReindexOf(account);
     setApiUser(account);
   }
@@ -2060,6 +2307,10 @@
     return gpgKeys;
   }
 
+  private Map<String, GpgKeyInfo> addGpgKeyNoReindex(String armored) throws Exception {
+    return gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+  }
+
   private void assertUser(AccountInfo info, TestAccount account) throws Exception {
     assertUser(info, account, null);
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
index e0fc358..0b7f340 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -17,10 +17,12 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.DeprecatedIdentifierException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Project;
 import org.junit.Before;
@@ -119,4 +121,27 @@
     exception.expect(ResourceNotFoundException.class);
     gApi.changes().id("I1234567890");
   }
+
+  @Test
+  @GerritConfig(
+    name = "change.api.allowedIdentifier",
+    values = {"PROJECT_NUMERIC_ID", "NUMERIC_ID"}
+  )
+  public void deprecatedChangeIdReturnsBadRequest() throws Exception {
+    // project~changeNumber still works
+    ChangeApi cApi1 = gApi.changes().id(project.get(), changeInfo._number);
+    assertThat(cApi1.get().changeId).isEqualTo(changeInfo.changeId);
+    // Change number still works
+    ChangeApi cApi2 = gApi.changes().id(changeInfo._number);
+    assertThat(cApi2.get().changeId).isEqualTo(changeInfo.changeId);
+    // IHash throws
+    ChangeInfo ci =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "different message")).get();
+    exception.expect(DeprecatedIdentifierException.class);
+    exception.expectMessage(
+        "The provided change identifier "
+            + ci.changeId
+            + " is deprecated. Use 'project~changeNumber' instead.");
+    gApi.changes().id(ci.changeId);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 38230d3..9ccc138 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -24,6 +24,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
 
 import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.ImmutableList;
@@ -43,20 +44,23 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIdReader;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gson.reflect.TypeToken;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
@@ -69,11 +73,14 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -82,11 +89,12 @@
 import org.junit.Test;
 
 public class ExternalIdIT extends AbstractDaemonTest {
-  @Inject private ExternalIdsUpdate.Server extIdsUpdate;
+  @Inject private AccountsUpdate.Server accountsUpdate;
   @Inject private ExternalIds externalIds;
   @Inject private ExternalIdReader externalIdReader;
   @Inject private MetricMaker metricMaker;
   @Inject private RetryHelper.Metrics retryMetrics;
+  @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
 
   @Test
   public void getExternalIds() throws Exception {
@@ -454,31 +462,28 @@
     return new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, message);
   }
 
-  private void insertValidExternalIds() throws IOException, ConfigInvalidException, OrmException {
+  private void insertValidExternalIds() throws Exception {
     MutableInteger i = new MutableInteger();
     String scheme = "valid";
-    ExternalIdsUpdate u = extIdsUpdate.create();
 
     // create valid external IDs
-    u.insert(
+    insertExtId(
         ExternalId.createWithPassword(
             ExternalId.Key.parse(nextId(scheme, i)),
             admin.id,
             "admin.other@example.com",
             "secret-password"));
-    u.insert(createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
+    insertExtId(createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
   }
 
-  private Set<ConsistencyProblemInfo> insertInvalidButParsableExternalIds()
-      throws IOException, ConfigInvalidException, OrmException {
+  private Set<ConsistencyProblemInfo> insertInvalidButParsableExternalIds() throws Exception {
     MutableInteger i = new MutableInteger();
     String scheme = "invalid";
-    ExternalIdsUpdate u = extIdsUpdate.create();
 
     Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
     ExternalId extIdForNonExistingAccount =
         createExternalIdForNonExistingAccount(nextId(scheme, i));
-    u.insert(extIdForNonExistingAccount);
+    insertExtIdForNonExistingAccount(extIdForNonExistingAccount);
     expectedProblems.add(
         consistencyError(
             "External ID '"
@@ -487,7 +492,7 @@
                 + extIdForNonExistingAccount.accountId().get()));
 
     ExternalId extIdWithInvalidEmail = createExternalIdWithInvalidEmail(nextId(scheme, i));
-    u.insert(extIdWithInvalidEmail);
+    insertExtId(extIdWithInvalidEmail);
     expectedProblems.add(
         consistencyError(
             "External ID '"
@@ -496,7 +501,7 @@
                 + extIdWithInvalidEmail.email()));
 
     ExternalId extIdWithDuplicateEmail = createExternalIdWithDuplicateEmail(nextId(scheme, i));
-    u.insert(extIdWithDuplicateEmail);
+    insertExtId(extIdWithDuplicateEmail);
     expectedProblems.add(
         consistencyError(
             "Email '"
@@ -508,7 +513,7 @@
                 + "'"));
 
     ExternalId extIdWithBadPassword = createExternalIdWithBadPassword("admin-username");
-    u.insert(extIdWithBadPassword);
+    insertExtId(extIdWithBadPassword);
     expectedProblems.add(
         consistencyError(
             "External ID '"
@@ -570,117 +575,117 @@
 
   private String insertExternalIdWithoutAccountId(Repository repo, RevWalk rw, String externalId)
       throws IOException {
-    ObjectId rev = ExternalIdReader.readRevision(repo);
-    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
-
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = extId.key().sha1();
-      Config c = new Config();
-      extId.writeToConfig(c);
-      c.unset("externalId", extId.key().get(), "accountId");
-      byte[] raw = c.toText().getBytes(UTF_8);
-      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-      noteMap.set(noteId, dataBlob);
-
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
-      return noteId.getName();
-    }
+    return insertExternalId(
+        repo,
+        rw,
+        (ins, noteMap) -> {
+          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+          ObjectId noteId = extId.key().sha1();
+          Config c = new Config();
+          extId.writeToConfig(c);
+          c.unset("externalId", extId.key().get(), "accountId");
+          byte[] raw = c.toText().getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
   }
 
   private String insertExternalIdWithKeyThatDoesntMatchNoteId(
       Repository repo, RevWalk rw, String externalId) throws IOException {
-    ObjectId rev = ExternalIdReader.readRevision(repo);
-    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
-
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
-      Config c = new Config();
-      extId.writeToConfig(c);
-      byte[] raw = c.toText().getBytes(UTF_8);
-      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-      noteMap.set(noteId, dataBlob);
-
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
-      return noteId.getName();
-    }
+    return insertExternalId(
+        repo,
+        rw,
+        (ins, noteMap) -> {
+          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+          ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
+          Config c = new Config();
+          extId.writeToConfig(c);
+          byte[] raw = c.toText().getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
   }
 
   private String insertExternalIdWithInvalidConfig(Repository repo, RevWalk rw, String externalId)
       throws IOException {
-    ObjectId rev = ExternalIdReader.readRevision(repo);
-    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-
-    try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
-      byte[] raw = "bad-config".getBytes(UTF_8);
-      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-      noteMap.set(noteId, dataBlob);
-
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
-      return noteId.getName();
-    }
+    return insertExternalId(
+        repo,
+        rw,
+        (ins, noteMap) -> {
+          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          byte[] raw = "bad-config".getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
   }
 
   private String insertExternalIdWithEmptyNote(Repository repo, RevWalk rw, String externalId)
       throws IOException {
+    return insertExternalId(
+        repo,
+        rw,
+        (ins, noteMap) -> {
+          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          byte[] raw = "".getBytes(UTF_8);
+          ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+          noteMap.set(noteId, dataBlob);
+          return noteId;
+        });
+  }
+
+  private String insertExternalId(Repository repo, RevWalk rw, ExternalIdInserter extIdInserter)
+      throws IOException {
     ObjectId rev = ExternalIdReader.readRevision(repo);
     NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
 
     try (ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
-      byte[] raw = "".getBytes(UTF_8);
-      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
-      noteMap.set(noteId, dataBlob);
+      ObjectId noteId = extIdInserter.addNote(ins, noteMap);
 
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
+      CommitBuilder cb = new CommitBuilder();
+      cb.setMessage("Update external IDs");
+      cb.setTreeId(noteMap.writeTree(ins));
+      cb.setAuthor(admin.getIdent());
+      cb.setCommitter(admin.getIdent());
+      if (!rev.equals(ObjectId.zeroId())) {
+        cb.setParentId(rev);
+      } else {
+        cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
+      }
+      if (cb.getTreeId() == null) {
+        if (rev.equals(ObjectId.zeroId())) {
+          cb.setTreeId(ins.insert(OBJ_TREE, new byte[] {})); // No parent, assume empty tree.
+        } else {
+          RevCommit p = rw.parseCommit(rev);
+          cb.setTreeId(p.getTree()); // Copy tree from parent.
+        }
+      }
+      ObjectId commitId = ins.insert(cb);
+      ins.flush();
+
+      RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
+      u.setExpectedOldObjectId(rev);
+      u.setNewObjectId(commitId);
+      RefUpdate.Result res = u.update();
+      switch (res) {
+        case NEW:
+        case FAST_FORWARD:
+        case NO_CHANGE:
+        case RENAMED:
+        case FORCED:
+          break;
+        case LOCK_FAILURE:
+        case IO_FAILURE:
+        case NOT_ATTEMPTED:
+        case REJECTED:
+        case REJECTED_CURRENT_BRANCH:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
+          throw new IOException("Updating external IDs failed with " + res);
+      }
       return noteId.getName();
     }
   }
@@ -718,15 +723,12 @@
     ExternalIdsUpdate update =
         new ExternalIdsUpdate(
             repoManager,
+            () -> metaDataUpdateFactory.create(allUsers),
             accountCache,
             allUsers,
             metricMaker,
             externalIds,
             new DisabledExternalIdCache(),
-            serverIdent.get(),
-            serverIdent.get(),
-            null,
-            GitReferenceUpdated.DISABLED,
             new RetryHelper(
                 cfg,
                 retryMetrics,
@@ -737,8 +739,8 @@
             () -> {
               if (!doneBgUpdate.getAndSet(true)) {
                 try {
-                  extIdsUpdate.create().insert(ExternalId.create(barId, admin.id));
-                } catch (IOException | ConfigInvalidException | OrmException e) {
+                  insertExtId(ExternalId.create(barId, admin.id));
+                } catch (Exception e) {
                   // Ignore, the successful insertion of the external ID is asserted later
                 }
               }
@@ -762,15 +764,12 @@
     ExternalIdsUpdate update =
         new ExternalIdsUpdate(
             repoManager,
+            () -> metaDataUpdateFactory.create(allUsers),
             accountCache,
             allUsers,
             metricMaker,
             externalIds,
             new DisabledExternalIdCache(),
-            serverIdent.get(),
-            serverIdent.get(),
-            null,
-            GitReferenceUpdated.DISABLED,
             new RetryHelper(
                 cfg,
                 retryMetrics,
@@ -782,10 +781,8 @@
                         .withBlockStrategy(noSleepBlockStrategy)),
             () -> {
               try {
-                extIdsUpdate
-                    .create()
-                    .insert(ExternalId.create(extIdsKeys[bgCounter.getAndAdd(1)], admin.id));
-              } catch (IOException | ConfigInvalidException | OrmException e) {
+                insertExtId(ExternalId.create(extIdsKeys[bgCounter.getAndAdd(1)], admin.id));
+              } catch (Exception e) {
                 // Ignore, the successful insertion of the external ID is asserted later
               }
             });
@@ -806,7 +803,12 @@
   public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
     ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
     Account.Id accountId = new Account.Id(1024 * 100);
-    extIdsUpdate.create().insert(ExternalId.create(extIdKey, accountId));
+    accountsUpdate
+        .create()
+        .insert(
+            "Create Account with Bad External ID",
+            accountId,
+            u -> u.addExternalId(ExternalId.create(extIdKey, accountId)));
     ExternalId extId = externalIds.get(extIdKey);
     assertThat(extId.accountId()).isEqualTo(accountId);
   }
@@ -817,20 +819,24 @@
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // insert external ID
       ExternalId extId = ExternalId.create("foo", "bar", admin.id);
-      extIdsUpdate.create().insert(extId);
+      insertExtId(extId);
       expectedExtIds.add(extId);
       assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
 
       // update external ID
       expectedExtIds.remove(extId);
-      extId = ExternalId.createWithEmail("foo", "bar", admin.id, "foo.bar@example.com");
-      extIdsUpdate.create().upsert(extId);
-      expectedExtIds.add(extId);
+      ExternalId extId2 = ExternalId.createWithEmail("foo", "bar", admin.id, "foo.bar@example.com");
+      accountsUpdate
+          .create()
+          .update("Update External ID", admin.id, u -> u.updateExternalId(extId2));
+      expectedExtIds.add(extId2);
       assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
 
       // delete external ID
-      extIdsUpdate.create().delete(extId);
-      expectedExtIds.remove(extId);
+      accountsUpdate
+          .create()
+          .update("Delete External ID", admin.id, u -> u.deleteExternalId(extId));
+      expectedExtIds.remove(extId2);
       assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExtIds);
     }
   }
@@ -866,50 +872,47 @@
     assertThat(externalIds.byAccount(admin.id)).containsExactlyElementsIn(expectedExternalIds);
   }
 
-  private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
+  private void insertExtId(ExternalId extId) throws Exception {
+    accountsUpdate
+        .create()
+        .update("Add External ID", extId.accountId(), u -> u.addExternalId(extId));
+  }
+
+  private void insertExtIdForNonExistingAccount(ExternalId extId) throws Exception {
+    // Cannot use AccountsUpdate to insert an external ID for a non-existing account.
     try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter ins = repo.newObjectInserter()) {
-      ObjectId rev = ExternalIdReader.readRevision(repo);
-      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
-      ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
-      ExternalIdsUpdate.commit(
-          allUsers,
-          repo,
-          rw,
-          ins,
-          rev,
-          noteMap,
-          "insert new ID",
-          serverIdent.get(),
-          serverIdent.get(),
-          null,
-          GitReferenceUpdated.DISABLED);
+        MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
+      extIdNotes.insert(extId);
+      extIdNotes.commit(update);
+      extIdNotes.updateCaches();
+    }
+  }
+
+  private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      // Inserting an external ID "behind Gerrit's back" means that the caches are not updated.
+      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(repo);
+      extIdNotes.insert(extId);
+      try (MetaDataUpdate metaDataUpdate =
+          new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
+        metaDataUpdate.getCommitBuilder().setAuthor(admin.getIdent());
+        metaDataUpdate.getCommitBuilder().setCommitter(admin.getIdent());
+        extIdNotes.commit(metaDataUpdate);
+      }
     }
   }
 
   private void addExtId(TestRepository<?> testRepo, ExternalId... extIds)
       throws IOException, OrmDuplicateKeyException, ConfigInvalidException {
-    ObjectId rev = ExternalIdReader.readRevision(testRepo.getRepository());
-
-    try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
-      NoteMap noteMap = ExternalIdReader.readNoteMap(testRepo.getRevWalk(), rev);
-      for (ExternalId extId : extIds) {
-        ExternalIdsUpdate.insert(testRepo.getRevWalk(), ins, noteMap, extId);
-      }
-
-      ExternalIdsUpdate.commit(
-          allUsers,
-          testRepo.getRepository(),
-          testRepo.getRevWalk(),
-          ins,
-          rev,
-          noteMap,
-          "Add external ID",
-          admin.getIdent(),
-          admin.getIdent(),
-          null,
-          GitReferenceUpdated.DISABLED);
+    ExternalIdNotes extIdNotes = externalIdNotesFactory.load(testRepo.getRepository());
+    extIdNotes.insert(Arrays.asList(extIds));
+    try (MetaDataUpdate metaDataUpdate =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, testRepo.getRepository())) {
+      metaDataUpdate.getCommitBuilder().setAuthor(admin.getIdent());
+      metaDataUpdate.getCommitBuilder().setCommitter(admin.getIdent());
+      extIdNotes.commit(metaDataUpdate);
+      extIdNotes.updateCaches();
     }
   }
 
@@ -950,4 +953,9 @@
       }
     };
   }
+
+  @FunctionalInterface
+  private interface ExternalIdInserter {
+    public ObjectId addNote(ObjectInserter ins, NoteMap noteMap) throws IOException;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/BUILD b/javatests/com/google/gerrit/acceptance/rest/config/BUILD
index 825523d..8550423 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/config/BUILD
@@ -4,4 +4,7 @@
     srcs = glob(["*IT.java"]),
     group = "rest_config",
     labels = ["rest"],
+    deps = [
+        "//java/com/google/gerrit/server/restapi",
+    ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index 2ef74b4..65ed7e4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -15,15 +15,15 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH;
-import static com.google.gerrit.server.config.PostCaches.Operation.FLUSH_ALL;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH;
+import static com.google.gerrit.server.restapi.config.PostCaches.Operation.FLUSH_ALL;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.config.PostCaches;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.restapi.config.PostCaches;
 import java.util.Arrays;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
index f196684..7133580 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ConfirmEmailIT.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.server.config.ConfirmEmail;
 import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.gerrit.server.restapi.config.ConfirmEmail;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index bc27fff..caecefa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
 import org.junit.Test;
 
 public class FlushCacheIT extends AbstractDaemonTest {
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
index fe600cc..247d63b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
@@ -18,8 +18,8 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.config.ListCaches.CacheType;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
 import org.junit.Test;
 
 public class GetCacheIT extends AbstractDaemonTest {
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index 900b4be..6d2c6dfa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -18,7 +18,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 import java.util.List;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
index 7cd9584..c19f5d0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/KillTaskIT.java
@@ -20,7 +20,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 import java.util.List;
 import java.util.Optional;
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
index 4d48bf4..ae17be0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
@@ -20,8 +20,8 @@
 import com.google.common.collect.Ordering;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.config.ListCaches.CacheType;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
 import com.google.gson.reflect.TypeToken;
 import java.util.Arrays;
 import java.util.List;
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
index ee6411a..674ca79 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListTasksIT.java
@@ -18,7 +18,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.config.ListTasks.TaskInfo;
+import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 import java.util.List;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 492245b..8ec145f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -56,7 +56,6 @@
   // change
   @GerritConfig(name = "change.allowDrafts", value = "false")
   @GerritConfig(name = "change.largeChange", value = "300")
-  @GerritConfig(name = "change.privateByDefault", value = "true")
   @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
   @GerritConfig(name = "change.replyLabel", value = "Vote")
   @GerritConfig(name = "change.updateDelay", value = "50s")
@@ -101,7 +100,6 @@
     // change
     assertThat(i.change.allowDrafts).isNull();
     assertThat(i.change.largeChange).isEqualTo(300);
-    assertThat(i.change.privateByDefault).isTrue();
     assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
     assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
     assertThat(i.change.updateDelay).isEqualTo(50);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 7b36126..348f027 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static java.util.stream.Collectors.groupingBy;
@@ -61,6 +62,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
@@ -945,6 +947,23 @@
     assertThat(getChangeSortedComments(changeId)).hasSize(3);
   }
 
+  @Test
+  public void jsonCommentHasLegacyFormatFalse() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    assertThat(noteUtil.getWriteJson()).isTrue();
+
+    PushOneCommit.Result result = createChange();
+    Change.Id changeId = result.getChange().getId();
+    addComment(result.getChangeId(), "comment");
+
+    Collection<com.google.gerrit.reviewdb.client.Comment> comments =
+        notesFactory.createChecked(db, project, changeId).getComments().values();
+    assertThat(comments).hasSize(1);
+    com.google.gerrit.reviewdb.client.Comment comment = comments.iterator().next();
+    assertThat(comment.message).isEqualTo("comment");
+    assertThat(comment.legacyFormat).isFalse();
+  }
+
   private List<CommentInfo> getChangeSortedComments(String changeId) throws Exception {
     List<CommentInfo> comments = new ArrayList<>();
     Map<String, List<CommentInfo>> commentsMap = getPublishedComments(changeId);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
new file mode 100644
index 0000000..a3a0339
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/LegacyCommentsIT.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.util.Collection;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class LegacyCommentsIT extends AbstractDaemonTest {
+  @Inject private ChangeNoteUtil noteUtil;
+
+  @ConfigSuite.Default
+  public static Config writeJsonFalseConfig() {
+    Config c = new Config();
+    c.setBoolean("noteDb", null, "writeJson", false);
+    return c;
+  }
+
+  @Before
+  public void setUp() {
+    setApiUser(user);
+  }
+
+  @Test
+  public void legacyCommentHasLegacyFormatTrue() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    assertThat(noteUtil.getWriteJson()).isFalse();
+
+    PushOneCommit.Result result = createChange();
+    Change.Id changeId = result.getChange().getId();
+
+    CommentInput cin = new CommentInput();
+    cin.message = "comment";
+    cin.path = PushOneCommit.FILE_NAME;
+
+    ReviewInput rin = new ReviewInput();
+    rin.comments = ImmutableMap.of(cin.path, ImmutableList.of(cin));
+    gApi.changes().id(changeId.get()).current().review(rin);
+
+    Collection<Comment> comments =
+        notesFactory.createChecked(db, project, changeId).getComments().values();
+    assertThat(comments).hasSize(1);
+    Comment comment = comments.iterator().next();
+    assertThat(comment.message).isEqualTo("comment");
+    assertThat(comment.legacyFormat).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index 3400fe6..98ab8f1 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -1287,6 +1287,28 @@
     assertThat(newPs3.getCreatedOn()).isGreaterThan(ps1.getCreatedOn());
   }
 
+  @Test
+  public void ignoreNoteDbStateWithNoCorrespondingRefWhenWritesAndReadsDisabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getChange().getId();
+    ReviewDb db = getUnwrappedDb();
+    Change c = db.changes().get(id);
+    c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    db.changes().update(Collections.singleton(c));
+    c = db.changes().get(id);
+
+    String refName = RefNames.changeMetaRef(id);
+    assertThat(getMetaRef(project, refName)).isNull();
+
+    ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
+    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
+
+    notes = notesFactory.createChecked(dbProvider.get(), project, id);
+    assertThat(notes.getChange().getRowVersion()).isEqualTo(c.getRowVersion());
+
+    assertThat(getMetaRef(project, refName)).isNull();
+  }
+
   private void assertChangesReadOnly(RestApiException e) throws Exception {
     Throwable cause = e.getCause();
     assertThat(cause).isInstanceOf(UpdateException.class);
diff --git a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 6d56a122..d130c20 100644
--- a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -40,7 +40,7 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -55,6 +55,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Set;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -84,7 +85,7 @@
 
   @Inject private ThreadLocalRequestContext requestContext;
 
-  @Inject private ExternalIdsUpdate.Server externalIdsUpdateFactory;
+  @Inject private ExternalIds externalIds;
 
   private LifecycleManager lifecycle;
   private ReviewDb db;
@@ -116,7 +117,9 @@
     schemaCreator.create(db);
     userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     // Note: does not match any key in TestKeys.
-    accountsUpdate.create().update(userId, a -> a.setPreferredEmail("user@example.com"));
+    accountsUpdate
+        .create()
+        .update("Set Preferred Email", userId, u -> u.setPreferredEmail("user@example.com"));
     user = reloadUser();
 
     requestContext.setContext(
@@ -219,8 +222,10 @@
 
   @Test
   public void noExternalIds() throws Exception {
-    ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
-    externalIdsUpdate.deleteAll(user.getAccountId());
+    Set<ExternalId> extIds = externalIds.byAccount(user.getAccountId());
+    accountsUpdate
+        .create()
+        .update("Delete External IDs", user.getAccountId(), u -> u.deleteExternalIds(extIds));
     reloadUser();
 
     TestKey key = validKeyWithSecondUserId();
@@ -233,9 +238,7 @@
     checker = checkerFactory.create().setStore(store).disableTrust();
     assertProblems(
         checker.check(key.getPublicKey()), Status.BAD, "Key is not associated with any users");
-    externalIdsUpdate.insert(
-        ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
-    reloadUser();
+    insertExtId(ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
     assertProblems(checker.check(key.getPublicKey()), Status.BAD, "No identities found for user");
   }
 
@@ -402,7 +405,7 @@
     cb.setCommitter(ident);
     assertThat(store.save(cb)).isAnyOf(NEW, FAST_FORWARD, FORCED);
 
-    externalIdsUpdateFactory.create().insert(newExtIds);
+    accountsUpdate.create().update("Add External IDs", id, u -> u.addExternalIds(newExtIds));
   }
 
   private TestKey add(TestKey k, IdentifiedUser user) throws Exception {
@@ -425,9 +428,13 @@
   }
 
   private void addExternalId(String scheme, String id, String email) throws Exception {
-    externalIdsUpdateFactory
+    insertExtId(ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
+  }
+
+  private void insertExtId(ExternalId extId) throws Exception {
+    accountsUpdate
         .create()
-        .insert(ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
+        .update("Add External ID", extId.accountId(), u -> u.addExternalId(extId));
     reloadUser();
   }
 }
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 5bdfe39..a228ed6 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -42,6 +42,7 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/org/eclipse/jgit:server",
diff --git a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
index 6fe48dc..bcba665 100644
--- a/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
+++ b/javatests/com/google/gerrit/server/config/ListCapabilitiesTest.java
@@ -22,7 +22,8 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.server.config.ListCapabilities.CapabilityInfo;
+import com.google.gerrit.server.restapi.config.ListCapabilities;
+import com.google.gerrit.server.restapi.config.ListCapabilities.CapabilityInfo;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 634f25e..77c139e 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -417,7 +418,7 @@
       md.getCommitBuilder().setCommitter(ident);
       AccountConfig accountConfig = new AccountConfig(null, accountId);
       accountConfig.load(repo);
-      accountConfig.getLoadedAccount().get().setFullName(newName);
+      accountConfig.setAccountUpdate(InternalAccountUpdate.builder().setFullName(newName).build());
       accountConfig.commit(md);
     }
 
@@ -541,11 +542,10 @@
       accountsUpdate
           .create()
           .update(
+              "Update Test Account",
               id,
-              a -> {
-                a.setFullName(fullName);
-                a.setPreferredEmail(email);
-                a.setActive(active);
+              u -> {
+                u.setFullName(fullName).setPreferredEmail(email).setActive(active);
               });
       return id;
     }
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 49a7e3e..0b7f94a 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -84,7 +84,6 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.PatchSetInserter;
@@ -172,7 +171,6 @@
   @Inject protected ThreadLocalRequestContext requestContext;
   @Inject protected ProjectCache projectCache;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-  @Inject protected ExternalIdsUpdate.Server externalIdsUpdate;
 
   // Only for use in setting up/tearing down injector; other users should use schemaFactory.
   @Inject private InMemoryDatabase inMemoryDatabase;
@@ -223,8 +221,12 @@
 
     userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     String email = "user@example.com";
-    externalIdsUpdate.create().insert(ExternalId.createEmail(userId, email));
-    accountsUpdate.create().update(userId, a -> a.setPreferredEmail(email));
+    accountsUpdate
+        .create()
+        .update(
+            "Add Email",
+            userId,
+            u -> u.addExternalId(ExternalId.createEmail(userId, email)).setPreferredEmail(email));
     user = userFactory.create(userId);
     requestContext.setContext(newRequestContext(userId));
   }
@@ -2729,11 +2731,10 @@
       accountsUpdate
           .create()
           .update(
+              "Update Test Account",
               id,
-              a -> {
-                a.setFullName(fullName);
-                a.setPreferredEmail(email);
-                a.setActive(active);
+              u -> {
+                u.setFullName(fullName).setPreferredEmail(email).setActive(active);
               });
       return id;
     }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index f3aaa6b..7dfc08c 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -409,11 +409,10 @@
       accountsUpdate
           .create()
           .update(
+              "Update Test Account",
               id,
-              a -> {
-                a.setFullName(fullName);
-                a.setPreferredEmail(email);
-                a.setActive(active);
+              u -> {
+                u.setFullName(fullName).setPreferredEmail(email).setActive(active);
               });
       return id;
     }
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index fddf4de..e450b42 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -264,11 +264,10 @@
       accountsUpdate
           .create()
           .update(
+              "Update Test Account",
               id,
-              a -> {
-                a.setFullName(fullName);
-                a.setPreferredEmail(email);
-                a.setActive(active);
+              u -> {
+                u.setFullName(fullName).setPreferredEmail(email).setActive(active);
               });
       return id;
     }
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index e737f47..6ad5859 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,8 +1,8 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "4.9.1.201712030800-r.66-gf8eff40ca"
+_JGIT_VERS = "4.9.2.201712150930-r.175-gd8a24ac1c"
 
-_DOC_VERS = "4.9.1.201712030800-r"  # Set to _JGIT_VERS unless using a snapshot
+_DOC_VERS = "4.9.2.201712150930-r"  # Set to _JGIT_VERS unless using a snapshot
 
 JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
@@ -26,28 +26,28 @@
         name = "jgit_lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "0b974aa9c6c929c39c506ab2705d42f3d7da84c7",
-        src_sha1 = "8884bef0415e092563b60b2167adbb09ac19d131",
+        sha1 = "4286555f5851fbfcf0ff89ec884f7f806b0c7e37",
+        src_sha1 = "5e38b7e7936ebbd778914dc4f9d76d245a5a4518",
         unsign = True,
     )
     maven_jar(
         name = "jgit_servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "19c6bcdf5e0ba1907f6eeb18ae02d6ae04f630e3",
+        sha1 = "da0d2c7a048cc213274cd06a5baf277c85ea152e",
         unsign = True,
     )
     maven_jar(
         name = "jgit_archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "0063dde3c017e05ee4e84ae16c97cb8817b91782",
+        sha1 = "1cd91bedf8b591626d341c2d896181ddba5f9aa9",
     )
     maven_jar(
         name = "jgit_junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "a9cb1e58df9bd876a2e81130f61a9bac0f182520",
+        sha1 = "5b7cc1aa0ba062ad587b6daa64743b704b997f74",
         unsign = True,
     )
 
diff --git a/plugins/BUILD b/plugins/BUILD
index 92a4c10..7c3fdd8 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -47,6 +47,7 @@
     "//lib/guice:multibindings",
     "//lib/httpcomponents:httpclient",
     "//lib/httpcomponents:httpcore",
+    "//lib/jackson:jackson-core",
     "//lib/jgit/org.eclipse.jgit.http.server:jgit-servlet",
     "//lib/jgit/org.eclipse.jgit:jgit",
     "//lib/log:api",
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
index 84f73ff..0bc9a99 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
@@ -83,11 +83,7 @@
     _getNameForUser(account) {
       const accountId = account._account_id ? ' (' +
         account._account_id + ')' : '';
-      if (account && account.username) {
-        return account.username + accountId;
-      } else if (account && account.name) {
-        return account.name + accountId;
-      }
+      return this._getNameForMember(account) + accountId;
     },
 
     _getNameForMember(account) {
@@ -95,6 +91,8 @@
         return account.name;
       } else if (account && account.username) {
         return account.username;
+      } else if (account && account.email) {
+        return account.email.split('@')[0];
       }
     },
   });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
index e437f7d..b179718 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
@@ -47,8 +47,8 @@
             _account_id: 12,
           },
         };
-        assert.deepEqual(
-            element._getNameForMember(account.member, false), 'test-user');
+        assert.equal(element._getNameForMember(account.member, false),
+            'test-user');
 
         account = {
           member: {
@@ -56,8 +56,14 @@
             _account_id: 12,
           },
         };
-        assert.deepEqual(
-            element._getNameForMember(account.member), 'test-name');
+        assert.equal(element._getNameForMember(account.member), 'test-name');
+
+        account = {
+          user: {
+            email: 'test-email@gmail.com',
+          },
+        };
+        assert.equal(element._getNameForMember(account.user), 'test-email');
       });
     });
 
@@ -69,8 +75,7 @@
             _account_id: 12,
           },
         };
-        assert.deepEqual(
-            element._getNameForUser(account.user), 'test-user (12)');
+        assert.equal(element._getNameForUser(account.user), 'test-user (12)');
 
         account = {
           user: {
@@ -78,8 +83,15 @@
             _account_id: 12,
           },
         };
-        assert.deepEqual(
-            element._getNameForUser(account.user), 'test-name (12)');
+        assert.equal(element._getNameForUser(account.user), 'test-name (12)');
+
+        account = {
+          user: {
+            email: 'test-email@gmail.com',
+            _account_id: 12,
+          },
+        };
+        assert.equal(element._getNameForUser(account.user), 'test-email (12)');
       });
 
       test('test _account_id not present', () => {
@@ -88,14 +100,21 @@
             username: 'test-user',
           },
         };
-        assert.deepEqual(element._getNameForUser(account.user), 'test-user');
+        assert.equal(element._getNameForUser(account.user), 'test-user');
 
         account = {
           user: {
             name: 'test-name',
           },
         };
-        assert.deepEqual(element._getNameForUser(account.user), 'test-name');
+        assert.equal(element._getNameForUser(account.user), 'test-name');
+
+        account = {
+          user: {
+            email: 'test-email@gmail.com',
+          },
+        };
+        assert.equal(element._getNameForUser(account.user), 'test-email');
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index a947af4..3189cd4 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -110,6 +110,11 @@
         align-items: center;
         display: flex;
       }
+      .fileViewActions gr-button {
+        --gr-button: {
+          padding: 2px 4px;
+        }
+      }
       .fileViewActions > *:not(:last-child) {
         margin-right: 5px;
       }
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 c969c51..8ce0e9d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -218,6 +218,9 @@
         .status {
           justify-content: flex-start;
         }
+        .reviewed {
+          display: none;
+        }
         .comments {
           min-width: initial;
         }
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 4c1326a..06799ff3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -1333,19 +1333,12 @@
     });
 
     suite('editLoaded behavior', () => {
-      const isVisible = el => {
-        assert.ok(el);
-        return getComputedStyle(el).getPropertyValue('display') !== 'none';
-      };
-
       test('reviewed checkbox', () => {
         const alertStub = sandbox.stub();
         const saveReviewStub = sandbox.stub(element, '_saveReviewedState');
 
         element.addEventListener('show-alert', alertStub);
         element.editLoaded = false;
-        // Reviewed checkbox should be shown.
-        assert.isTrue(isVisible(element.$$('.reviewed')));
         MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         assert.isFalse(alertStub.called);
         assert.isTrue(saveReviewStub.calledOnce);
@@ -1353,7 +1346,6 @@
         element.editLoaded = true;
         flushAsynchronousOperations();
 
-        assert.isFalse(isVisible(element.$$('.reviewed')));
         MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         assert.isTrue(alertStub.called);
         assert.isTrue(saveReviewStub.calledOnce);
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 968766d..cf743a4 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -66,7 +66,7 @@
     },
 
     _computeBlankItems(permittedLabels, label, side) {
-      if (!permittedLabels || !permittedLabels[label] ||
+      if (!permittedLabels || !permittedLabels[label] || !this.labelValues ||
           !Object.keys(this.labelValues).length) {
         return [];
       }
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index f43ff91..8ef8b7b 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -45,10 +45,7 @@
 
   native.java_binary(
     name = '%s__non_stamped' % name,
-    deploy_manifest_lines = manifest_entries + [
-      "Gerrit-ApiType: plugin",
-      "Implementation-Vendor: Gerrit Code Review",
-    ],
+    deploy_manifest_lines = manifest_entries + ["Gerrit-ApiType: plugin"],
     main_class = 'Dummy',
     runtime_deps = [
       ':%s__plugin' % name,