Merge "Include view name into log that records internal server errors"
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 77437b3..56fb748 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -493,9 +492,6 @@
   /** References the source change and patchset that this change was cherry-picked from. */
   @Nullable private PatchSet.Id cherryPickOf;
 
-  /** Custom keyed values that were provided during change creation. */
-  @Nullable private ImmutableMap<String, String> customKeyedValues;
-
   Change() {}
 
   public Change(
@@ -527,7 +523,6 @@
     reviewStarted = other.reviewStarted;
     revertOf = other.revertOf;
     cherryPickOf = other.cherryPickOf;
-    customKeyedValues = other.customKeyedValues;
   }
 
   /** 32 bit integer identity for a change. */
@@ -718,14 +713,6 @@
     this.cherryPickOf = cherryPickOf;
   }
 
-  public void setCustomKeyedValues(ImmutableMap<String, String> customKeyedValues) {
-    this.customKeyedValues = customKeyedValues;
-  }
-
-  public ImmutableMap<String, String> getCustomKeyedValues() {
-    return customKeyedValues;
-  }
-
   @Override
   public String toString() {
     return new StringBuilder(getClass().getSimpleName())
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index f2331e3..e4fa718 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -95,7 +95,7 @@
   }
 
   @Override
-  protected String getRefName() {
+  public String getRefName() {
     return ref;
   }
 
@@ -186,7 +186,7 @@
    * @return the new account
    * @throws DuplicateKeyException if the user branch already exists
    */
-  Account getNewAccount(Instant registeredOn) throws DuplicateKeyException {
+  public Account getNewAccount(Instant registeredOn) throws DuplicateKeyException {
     checkLoaded();
     if (revision != null) {
       throw new DuplicateKeyException(String.format("account %s already exists", accountId));
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index e801b14..2080e93 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -14,123 +14,39 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE;
-import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
-import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes.ExternalIdNotesLoader;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.account.storage.notedb.AccountsNoteDbImpl;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.CachedPreferences;
-import com.google.gerrit.server.config.VersionedDefaultPreferences;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
 import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryableAction.Action;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
+import com.google.inject.BindingAnnotation;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashSet;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 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.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
  * Creates and updates accounts.
  *
- * <p>This class should be used for all account updates. See {@link AccountDelta} for what can be
- * updated.
- *
- * <p>Batch updates of multiple different accounts can be performed atomically, see {@link
- * #updateBatch(List)}. Batch creation is not supported.
- *
- * <p>For any account update the caller must provide a commit message, the account ID and an {@link
- * ConfigureDeltaFromState}. The account updater reads the current {@link AccountState} and prepares
- * updates to the account by calling setters on the provided {@link AccountDelta.Builder}. If the
- * current account state is of no interest the caller may also provide a {@link Consumer} for {@link
- * AccountDelta.Builder} instead of the account updater.
- *
- * <p>The provided commit message is used for the update of the user branch. Using a precise and
- * unique commit message allows to identify the code from which an update was made when looking at a
- * commit in the user branch, and thus help debugging.
+ * <p>This interface should be used for all account updates. See {@link AccountDelta} for what can
+ * be updated.
  *
  * <p>For creating a new account a new account ID can be retrieved from {@link
  * Sequences#nextAccountId()}.
  *
- * <p>The account updates are written to NoteDb. In NoteDb accounts are represented as user branches
- * in the {@code All-Users} repository. Optionally a user branch can contain a 'account.config' file
- * that stores account properties, such as full name, display name, preferred email, status and the
- * active flag. The timestamp of the first commit on a user branch denotes the registration date.
- * The initial commit on the user branch may be empty (since having an 'account.config' is
- * optional). See {@link AccountConfig} for details of the 'account.config' file format. In addition
- * the user branch can contain a 'preferences.config' config file to store preferences (see {@link
- * StoredPreferences}) and a 'watch.config' config file to store project watches (see {@link
- * ProjectWatches}). External IDs are stored separately in the {@code refs/meta/external-ids} notes
- * branch (see {@link ExternalIdNotes}).
- *
- * <p>On updating an account the account is evicted from the account cache and reindexed. The
- * eviction from the account cache and the reindexing is done by the {@link ReindexAfterRefUpdate}
- * class which receives the event about updating the user branch that is triggered by this class.
- *
- * <p>If external IDs are updated, the ExternalIdCache is automatically updated by {@link
- * ExternalIdNotes}. In addition {@link ExternalIdNotes} takes care about evicting and reindexing
- * corresponding accounts. This is needed because external ID updates don't touch the user branches.
- * Hence in this case the accounts are not evicted and reindexed via {@link ReindexAfterRefUpdate}.
- *
- * <p>Reindexing and flushing accounts from the account cache can be disabled by
- *
- * <ul>
- *   <li>binding {@link GitReferenceUpdated#DISABLED} and
- *   <li>passing an {@link
- *       com.google.gerrit.server.account.externalids.ExternalIdNotes.FactoryNoReindex} factory as
- *       parameter of {@link AccountsUpdate.Factory#create(IdentifiedUser,
- *       ExternalIdNotes.ExternalIdNotesLoader)}
- * </ul>
- *
- * <p>If there are concurrent account updates updating the user branch in NoteDb may fail with
- * {@link LockFailureException}. In this case the account update is automatically retried and the
- * account updater is invoked once more with the updated account state. This means the whole
- * read-modify-write sequence is atomic. Retrying is limited by a timeout. If the timeout is
- * exceeded the account update can still fail with {@link LockFailureException}.
+ * <p>See the implementing classes for more information.
  */
-public class AccountsUpdate {
-  public interface Factory {
+public interface AccountsUpdate {
+  abstract class AccountsUpdateLoader {
     /**
      * Creates an {@code AccountsUpdate} which uses the identity of the specified user as author for
      * all commits related to accounts. The server identity will be used as committer.
@@ -140,9 +56,8 @@
      * AccountsUpdate} instead.
      *
      * @param currentUser the user to which modifications should be attributed
-     * @param externalIdNotesLoader the loader that should be used to load external ID notes
      */
-    AccountsUpdate create(IdentifiedUser currentUser, ExternalIdNotesLoader externalIdNotesLoader);
+    public abstract AccountsUpdate create(IdentifiedUser currentUser);
 
     /**
      * Creates an {@code AccountsUpdate} which uses the server identity as author and committer for
@@ -151,10 +66,34 @@
      * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
      * com.google.gerrit.server.ServerInitiated} annotation on the provider of an {@code
      * AccountsUpdate} instead.
-     *
-     * @param externalIdNotesLoader the loader that should be used to load external ID notes
      */
-    AccountsUpdate createWithServerIdent(ExternalIdNotesLoader externalIdNotesLoader);
+    public abstract AccountsUpdate createWithServerIdent();
+
+    @BindingAnnotation
+    @Target({FIELD, PARAMETER, METHOD})
+    @Retention(RUNTIME)
+    public @interface WithReindex {}
+
+    @BindingAnnotation
+    @Target({FIELD, PARAMETER, METHOD})
+    @Retention(RUNTIME)
+    public @interface NoReindex {}
+  }
+
+  /** Data holder for the set of arguments required to update an account. Used for batch updates. */
+  class UpdateArguments {
+    public final String message;
+    public final Account.Id accountId;
+    public final AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState;
+
+    public UpdateArguments(
+        String message,
+        Account.Id accountId,
+        AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState) {
+      this.message = message;
+      this.accountId = accountId;
+      this.configureDeltaFromState = configureDeltaFromState;
+    }
   }
 
   /**
@@ -162,13 +101,13 @@
    * delta to be applied to it in a later step. This is done by implementing this interface.
    *
    * <p>If the current account state is not needed, use a {@link Consumer} of {@link
-   * AccountDelta.Builder} instead.
+   * com.google.gerrit.server.account.AccountDelta.Builder} instead.
    */
   @FunctionalInterface
-  public interface ConfigureDeltaFromState {
+  interface ConfigureDeltaFromState {
     /**
      * Receives the current {@link AccountState} (which is immutable) and configures an {@link
-     * AccountDelta.Builder} with changes to the account.
+     * com.google.gerrit.server.account.AccountDelta.Builder} with changes to the account.
      *
      * @param accountState the state of the account that is being updated
      * @param delta the changes to be applied
@@ -176,145 +115,18 @@
     void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
   }
 
-  /** Data holder for the set of arguments required to update an account. Used for batch updates. */
-  public static class UpdateArguments {
-    private final String message;
-    private final Account.Id accountId;
-    private final ConfigureDeltaFromState configureDeltaFromState;
-
-    public UpdateArguments(
-        String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState) {
-      this.message = message;
-      this.accountId = accountId;
-      this.configureDeltaFromState = configureDeltaFromState;
-    }
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final Optional<IdentifiedUser> currentUser;
-  private final AllUsersName allUsersName;
-  private final ExternalIds externalIds;
-  private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
-  private final RetryHelper retryHelper;
-  private final ExternalIdNotesLoader extIdNotesLoader;
-  private final PersonIdent committerIdent;
-  private final PersonIdent authorIdent;
-
-  /** Invoked after reading the account config. */
-  private final Runnable afterReadRevision;
-
-  /** Invoked after updating the account but before committing the changes. */
-  private final Runnable beforeCommit;
-
-  /** Single instance that accumulates updates from the batch. */
-  @Nullable private ExternalIdNotes externalIdNotes;
-
-  @AssistedInject
-  AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
-      RetryHelper retryHelper,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Assisted ExternalIdNotesLoader extIdNotesLoader) {
-    this(
-        repoManager,
-        gitRefUpdated,
-        Optional.empty(),
-        allUsersName,
-        externalIds,
-        metaDataUpdateInternalFactory,
-        retryHelper,
-        extIdNotesLoader,
-        serverIdent,
-        createPersonIdent(serverIdent, Optional.empty()),
-        AccountsUpdate::doNothing,
-        AccountsUpdate::doNothing);
-  }
-
-  @AssistedInject
-  AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
-      RetryHelper retryHelper,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Assisted IdentifiedUser currentUser,
-      @Assisted ExternalIdNotesLoader extIdNotesLoader) {
-    this(
-        repoManager,
-        gitRefUpdated,
-        Optional.of(currentUser),
-        allUsersName,
-        externalIds,
-        metaDataUpdateInternalFactory,
-        retryHelper,
-        extIdNotesLoader,
-        serverIdent,
-        createPersonIdent(serverIdent, Optional.of(currentUser)),
-        AccountsUpdate::doNothing,
-        AccountsUpdate::doNothing);
-  }
-
-  @VisibleForTesting
-  public AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      Optional<IdentifiedUser> currentUser,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
-      RetryHelper retryHelper,
-      ExternalIdNotesLoader extIdNotesLoader,
-      PersonIdent committerIdent,
-      PersonIdent authorIdent,
-      Runnable afterReadRevision,
-      Runnable beforeCommit) {
-    this.repoManager = requireNonNull(repoManager, "repoManager");
-    this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
-    this.currentUser = currentUser;
-    this.allUsersName = requireNonNull(allUsersName, "allUsersName");
-    this.externalIds = requireNonNull(externalIds, "externalIds");
-    this.metaDataUpdateInternalFactory =
-        requireNonNull(metaDataUpdateInternalFactory, "metaDataUpdateInternalFactory");
-    this.retryHelper = requireNonNull(retryHelper, "retryHelper");
-    this.extIdNotesLoader = requireNonNull(extIdNotesLoader, "extIdNotesLoader");
-    this.committerIdent = requireNonNull(committerIdent, "committerIdent");
-    this.authorIdent = requireNonNull(authorIdent, "authorIdent");
-    this.afterReadRevision = requireNonNull(afterReadRevision, "afterReadRevision");
-    this.beforeCommit = requireNonNull(beforeCommit, "beforeCommit");
-  }
-
   /** Returns an instance that runs all specified consumers. */
-  public static ConfigureDeltaFromState joinConsumers(
-      List<Consumer<AccountDelta.Builder>> consumers) {
+  static ConfigureDeltaFromState joinConsumers(List<Consumer<AccountDelta.Builder>> consumers) {
     return (accountStateIgnored, update) -> consumers.forEach(c -> c.accept(update));
   }
 
-  private static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
-    return (a, u) -> consumer.accept(u);
-  }
-
-  private static PersonIdent createPersonIdent(
-      PersonIdent serverIdent, Optional<IdentifiedUser> user) {
-    return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
-  }
-
   /**
    * Like {@link #insert(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
    * instead, i.e. the update does not depend on the current account state (which, for insertion,
    * would only contain the account ID).
    */
-  public AccountState insert(
-      String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
-      throws IOException, ConfigInvalidException {
-    return insert(message, accountId, fromConsumer(init));
-  }
+  AccountState insert(String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
+      throws IOException, ConfigInvalidException;
 
   /**
    * Inserts a new account.
@@ -327,41 +139,16 @@
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
-      throws IOException, ConfigInvalidException {
-    return execute(
-            ImmutableList.of(
-                repo -> {
-                  AccountConfig accountConfig = read(repo, accountId);
-                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
-                  AccountState accountState = AccountState.forAccount(account);
-                  AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-                  init.configure(accountState, deltaBuilder);
-
-                  AccountDelta accountDelta = deltaBuilder.build();
-                  accountConfig.setAccountDelta(accountDelta);
-                  externalIdNotes =
-                      createExternalIdNotes(
-                          repo, accountConfig.getExternalIdsRev(), accountId, accountDelta);
-                  CachedPreferences defaultPreferences =
-                      CachedPreferences.fromConfig(
-                          VersionedDefaultPreferences.get(repo, allUsersName));
-
-                  return new UpdatedAccount(message, accountConfig, defaultPreferences, true);
-                }))
-        .get(0)
-        .get();
-  }
+  AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
+      throws IOException, ConfigInvalidException;
 
   /**
    * Like {@link #update(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
    * instead, i.e. the update does not depend on the current account state.
    */
-  public Optional<AccountState> update(
+  Optional<AccountState> update(
       String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
-      throws IOException, ConfigInvalidException {
-    return update(message, accountId, fromConsumer(update));
-  }
+      throws IOException, ConfigInvalidException;
 
   /**
    * Gets the account and updates it atomically.
@@ -378,76 +165,9 @@
    *     after the retry timeout exceeded
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public Optional<AccountState> update(
+  Optional<AccountState> update(
       String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
-      throws LockFailureException, IOException, ConfigInvalidException {
-    return updateBatch(
-            ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
-        .get(0);
-  }
-
-  /**
-   * Deletes all the account state data.
-   *
-   * @param message commit message for the account update, must not be {@code null or empty}
-   * @param accountId ID of the account
-   * @throws IOException if updating the user branch fails due to an IO error
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
-   */
-  public void delete(String message, Account.Id accountId)
-      throws IOException, ConfigInvalidException {
-    ImmutableSet<ExternalId> accountExternalIds = externalIds.byAccount(accountId);
-    Consumer<AccountDelta.Builder> delta =
-        deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
-    update(message, accountId, delta);
-  }
-
-  private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
-    return repo -> {
-      AccountConfig accountConfig = read(repo, updateArguments.accountId);
-      CachedPreferences defaultPreferences =
-          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
-      Optional<AccountState> accountState =
-          AccountsNoteDbImpl.getFromAccountConfig(externalIds, accountConfig, defaultPreferences);
-      if (!accountState.isPresent()) {
-        return null;
-      }
-
-      AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
-
-      AccountDelta delta = deltaBuilder.build();
-
-      ExternalIdNotes.checkSameAccount(
-          Iterables.concat(
-              delta.getCreatedExternalIds(),
-              delta.getUpdatedExternalIds(),
-              delta.getDeletedExternalIds()),
-          updateArguments.accountId);
-
-      if (delta.hasExternalIdUpdates()) {
-        // Only load the externalIds if they are going to be updated
-        // This makes e.g. preferences updates faster.
-        if (externalIdNotes == null) {
-          externalIdNotes =
-              extIdNotesLoader.load(
-                  repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
-        }
-        externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
-        externalIdNotes.upsert(delta.getUpdatedExternalIds());
-      }
-
-      if (delta.getShouldDeleteAccount().orElse(false)) {
-        return new DeletedAccount(updateArguments.message, accountConfig.getRefName());
-      }
-
-      accountConfig.setAccountDelta(delta);
-      CachedPreferences cachedDefaultPreferences =
-          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
-      return new UpdatedAccount(
-          updateArguments.message, accountConfig, cachedDefaultPreferences, false);
-    };
-  }
+      throws LockFailureException, IOException, ConfigInvalidException;
 
   /**
    * Updates multiple different accounts atomically. This will only store a single new value (aka
@@ -459,238 +179,16 @@
    * the error. Callers should be aware that a single "update of death" (or a set of updates that
    * together have this property) will always prevent the entire batch from being executed.
    */
-  public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
-      throws IOException, ConfigInvalidException {
-    checkArgument(
-        updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
-        "updates must all be for different accounts");
-    return execute(updates.stream().map(this::createExecutableUpdate).collect(toList()));
-  }
+  ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
+      throws IOException, ConfigInvalidException;
 
-  private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
-      throws IOException, ConfigInvalidException {
-    AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
-    afterReadRevision.run();
-    return accountConfig;
-  }
-
-  private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
-      throws IOException, ConfigInvalidException {
-    try (RefUpdateContext ctx = RefUpdateContext.open(ACCOUNTS_UPDATE)) {
-      List<Optional<AccountState>> accountState = new ArrayList<>();
-      List<UpdatedAccount> updatedAccounts = new ArrayList<>();
-      executeWithRetry(
-          () -> {
-
-            // Reset state for retry.
-            externalIdNotes = null;
-            accountState.clear();
-            updatedAccounts.clear();
-            try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-              for (ExecutableUpdate executableUpdate : executableUpdates) {
-                updatedAccounts.add(executableUpdate.execute(allUsersRepo));
-              }
-              commit(
-                  allUsersRepo,
-                  updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
-              for (UpdatedAccount ua : updatedAccounts) {
-                accountState.add(
-                    ua == null || ua.deleted ? Optional.empty() : ua.getAccountState());
-              }
-            }
-            return null;
-          });
-
-      return ImmutableList.copyOf(accountState);
-    }
-  }
-
-  private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
-    try {
-      retryHelper.accountUpdate("updateAccount", action).call();
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      Throwables.throwIfInstanceOf(e, IOException.class);
-      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
-      throw new StorageException(e);
-    }
-  }
-
-  private ExternalIdNotes createExternalIdNotes(
-      Repository allUsersRepo, Optional<ObjectId> rev, Account.Id accountId, AccountDelta update)
-      throws IOException, ConfigInvalidException, DuplicateKeyException {
-    ExternalIdNotes.checkSameAccount(
-        Iterables.concat(
-            update.getCreatedExternalIds(),
-            update.getUpdatedExternalIds(),
-            update.getDeletedExternalIds()),
-        accountId);
-
-    ExternalIdNotes extIdNotes = extIdNotesLoader.load(allUsersRepo, rev.orElse(ObjectId.zeroId()));
-    extIdNotes.replace(update.getDeletedExternalIds(), update.getCreatedExternalIds());
-    extIdNotes.upsert(update.getUpdatedExternalIds());
-    return extIdNotes;
-  }
-
-  private void commit(Repository allUsersRepo, List<UpdatedAccount> updatedAccounts)
-      throws IOException {
-    if (updatedAccounts.isEmpty()) {
-      return;
-    }
-
-    beforeCommit.run();
-
-    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-    Set<Account.Id> accountsToSkipForReindex = new HashSet<>();
-    //  External ids may be not updated if:
-    //  * externalIdNotes is not loaded  (there were no externalId updates in the delta)
-    //  * new revCommit is identical to the previous externalId tip
-    boolean externalIdsUpdated = false;
-    if (externalIdNotes != null) {
-      String externalIdUpdateMessage =
-          updatedAccounts.size() == 1
-              ? Iterables.getOnlyElement(updatedAccounts).message
-              : "Batch update for " + updatedAccounts.size() + " accounts";
-      ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
-      // These update the same ref, so they need to be stacked on top of one another using the same
-      // ExternalIdNotes instance.
-      RevCommit revCommit =
-          commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
-      externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
-    }
-    for (UpdatedAccount updatedAccount : updatedAccounts) {
-      if (updatedAccount.deleted) {
-        RefUpdate ru = RefUpdateUtil.deleteChecked(allUsersRepo, updatedAccount.refName);
-        gitRefUpdated.fire(allUsersName, ru, ReceiveCommand.Type.DELETE, null);
-        accountsToSkipForReindex.add(Account.Id.fromRef(updatedAccount.refName));
-        continue;
-      }
-      // These updates are all for different refs (because batches never update the same account
-      // more than once), so there can be multiple commits in the same batch, all with the same base
-      // revision in their AccountConfig.
-      // We allow empty commits:
-      // 1) When creating a new account, 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.
-      // 2) When updating "refs/meta/external-ids", so that refs/users/* meta ref is updated too.
-      // This allows to schedule reindexing of account transactionally on refs/users/* meta
-      // updates.
-      boolean allowEmptyCommit = externalIdsUpdated || updatedAccount.created;
-      commitAccountConfig(
-          updatedAccount.message,
-          allUsersRepo,
-          batchRefUpdate,
-          updatedAccount.accountConfig,
-          allowEmptyCommit);
-    }
-
-    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
-
-    if (externalIdsUpdated) {
-      accountsToSkipForReindex.addAll(getUpdatedAccountIds(batchRefUpdate));
-      extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
-          externalIdNotes, accountsToSkipForReindex);
-    }
-
-    gitRefUpdated.fire(
-        allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
-  }
-
-  private static Set<Account.Id> getUpdatedAccountIds(BatchRefUpdate batchRefUpdate) {
-    return batchRefUpdate.getCommands().stream()
-        .map(c -> Account.Id.fromRef(c.getRefName()))
-        .filter(Objects::nonNull)
-        .collect(toSet());
-  }
-
-  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 RevCommit commitExternalIdUpdates(
-      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
-    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      return externalIdNotes.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;
-  }
-
-  private static void doNothing() {}
-
-  @FunctionalInterface
-  private interface ExecutableUpdate {
-    UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
-  }
-
-  private class UpdatedAccount {
-    final String message;
-    final AccountConfig accountConfig;
-    final CachedPreferences defaultPreferences;
-    final String refName;
-    final boolean created;
-    final boolean deleted;
-
-    UpdatedAccount(
-        String message,
-        AccountConfig accountConfig,
-        CachedPreferences defaultPreferences,
-        boolean created) {
-      this(
-          message,
-          requireNonNull(accountConfig),
-          defaultPreferences,
-          accountConfig.getRefName(),
-          created,
-          false);
-    }
-
-    protected UpdatedAccount(
-        String message,
-        AccountConfig accountConfig,
-        CachedPreferences defaultPreferences,
-        String refName,
-        boolean created,
-        boolean deleted) {
-      checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
-      this.message = requireNonNull(message);
-      this.accountConfig = accountConfig;
-      this.defaultPreferences = defaultPreferences;
-      this.refName = refName;
-      this.created = created;
-      this.deleted = deleted;
-    }
-
-    Optional<AccountState> getAccountState() throws IOException {
-      return AccountsNoteDbImpl.getFromAccountConfig(
-          externalIds, accountConfig, externalIdNotes, defaultPreferences);
-    }
-  }
-
-  private class DeletedAccount extends UpdatedAccount {
-    DeletedAccount(String message, String refName) {
-      super(message, null, null, refName, false, true);
-    }
-  }
+  /**
+   * Deletes all the account state data.
+   *
+   * @param message commit message for the account update, must not be {@code null or empty}
+   * @param accountId ID of the account
+   * @throws IOException if updating the user branch fails due to an IO error
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  void delete(String message, Account.Id accountId) throws IOException, ConfigInvalidException;
 }
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbStorageModule.java b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbStorageModule.java
index 4cbe48f..51f6b86 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbStorageModule.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbStorageModule.java
@@ -14,13 +14,25 @@
 
 package com.google.gerrit.server.account.storage.notedb;
 
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
 import com.google.inject.AbstractModule;
 import com.google.inject.Singleton;
 
 public class AccountNoteDbStorageModule extends AbstractModule {
   @Override
   protected void configure() {
-    binder().bind(Accounts.class).to(AccountsNoteDbImpl.class).in(Singleton.class);
+    bind(Accounts.class).to(AccountsNoteDbImpl.class).in(Singleton.class);
+
+    DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
+
+    bind(AccountsUpdate.AccountsUpdateLoader.class)
+        .annotatedWith(AccountsUpdate.AccountsUpdateLoader.WithReindex.class)
+        .to(AccountsUpdateNoteDbImpl.Factory.class);
+    bind(AccountsUpdate.AccountsUpdateLoader.class)
+        .annotatedWith(AccountsUpdate.AccountsUpdateLoader.NoReindex.class)
+        .to(AccountsUpdateNoteDbImpl.FactoryNoReindex.class);
   }
 }
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
index ce8cd4d..9fb7917 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
@@ -141,7 +141,7 @@
    * @return the account state, {@link Optional#empty()} if the account doesn't exist
    * @throws IOException if accessing the external IDs fails
    */
-  public static Optional<AccountState> getFromAccountConfig(
+  static Optional<AccountState> getFromAccountConfig(
       ExternalIds externalIds, AccountConfig accountConfig, CachedPreferences defaultPreferences)
       throws IOException {
     return getFromAccountConfig(externalIds, accountConfig, null, defaultPreferences);
@@ -164,7 +164,7 @@
    * @return the account state, {@link Optional#empty()} if the account doesn't exist
    * @throws IOException if accessing the external IDs fails
    */
-  public static Optional<AccountState> getFromAccountConfig(
+  static Optional<AccountState> getFromAccountConfig(
       ExternalIds externalIds,
       AccountConfig accountConfig,
       @Nullable ExternalIdNotes extIdNotes,
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
new file mode 100644
index 0000000..cc8ef14
--- /dev/null
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -0,0 +1,642 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.storage.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountDelta;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.ProjectWatches;
+import com.google.gerrit.server.account.StoredPreferences;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.CachedPreferences;
+import com.google.gerrit.server.config.VersionedDefaultPreferences;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryableAction.Action;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Consumer;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+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.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Creates and updates accounts which are stored in All-Users NoteDB repository.
+ *
+ * <p>Batch updates of multiple different accounts can be performed atomically, see {@link
+ * #updateBatch(List)}. Batch creation is not supported.
+ *
+ * <p>For any account update the caller must provide a commit message, the account ID and an {@link
+ * com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState}. The account updater
+ * reads the current {@link AccountState} and prepares updates to the account by calling setters on
+ * the provided {@link com.google.gerrit.server.account.AccountDelta.Builder}. If the current
+ * account state is of no interest the caller may also provide a {@link Consumer} for {@link
+ * com.google.gerrit.server.account.AccountDelta.Builder} instead of the account updater.
+ *
+ * <p>The provided commit message is used for the update of the user branch. Using a precise and
+ * unique commit message allows to identify the code from which an update was made when looking at a
+ * commit in the user branch, and thus help debugging.
+ *
+ * <p>The account updates are written to NoteDb. In NoteDb accounts are represented as user branches
+ * in the {@code All-Users} repository. Optionally a user branch can contain a 'account.config' file
+ * that stores account properties, such as full name, display name, preferred email, status and the
+ * active flag. The timestamp of the first commit on a user branch denotes the registration date.
+ * The initial commit on the user branch may be empty (since having an 'account.config' is
+ * optional). See {@link AccountConfig} for details of the 'account.config' file format. In addition
+ * the user branch can contain a 'preferences.config' config file to store preferences (see {@link
+ * StoredPreferences}) and a 'watch.config' config file to store project watches (see {@link
+ * ProjectWatches}). External IDs are stored separately in the {@code refs/meta/external-ids} notes
+ * branch (see {@link ExternalIdNotes}).
+ *
+ * <p>On updating an account the account is evicted from the account cache and reindexed. The
+ * eviction from the account cache and the reindexing is done by the {@link ReindexAfterRefUpdate}
+ * class which receives the event about updating the user branch that is triggered by this class.
+ *
+ * <p>If external IDs are updated, the ExternalIdCache is automatically updated by {@link
+ * ExternalIdNotes}. In addition {@link ExternalIdNotes} takes care about evicting and reindexing
+ * corresponding accounts. This is needed because external ID updates don't touch the user branches.
+ * Hence in this case the accounts are not evicted and reindexed via {@link ReindexAfterRefUpdate}.
+ *
+ * <p>Reindexing and flushing accounts from the account cache can be disabled by
+ *
+ * <ul>
+ *   <li>using {@link
+ *       com.google.gerrit.server.account.storage.notedb.AccountsUpdateNoteDbImpl.FactoryNoReindex}
+ *       and
+ *   <li>binding {@link GitReferenceUpdated#DISABLED}
+ * </ul>
+ *
+ * <p>If there are concurrent account updates which updating the user branch in NoteDb may fail with
+ * {@link LockFailureException}. In this case the account update is automatically retried and the
+ * account updater is invoked once more with the updated account state. This means the whole
+ * read-modify-write sequence is atomic. Retrying is limited by a timeout. If the timeout is
+ * exceeded the account update can still fail with {@link LockFailureException}.
+ */
+public class AccountsUpdateNoteDbImpl implements AccountsUpdate {
+  private static class AbstractFactory extends AccountsUpdateLoader {
+    GitRepositoryManager repoManager;
+    GitReferenceUpdated gitRefUpdated;
+    AllUsersName allUsersName;
+    ExternalIds externalIds;
+    ExternalIdNotes.ExternalIdNotesLoader extIdNotesFactory;
+    Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+    RetryHelper retryHelper;
+    PersonIdent serverIdent;
+
+    private AbstractFactory(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        ExternalIds externalIds,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        @GerritPersonIdent PersonIdent serverIdent,
+        ExternalIdNotes.ExternalIdNotesLoader extIdNotesFactory) {
+      this.repoManager = repoManager;
+      this.gitRefUpdated = gitRefUpdated;
+      this.allUsersName = allUsersName;
+      this.externalIds = externalIds;
+      this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
+      this.retryHelper = retryHelper;
+      this.serverIdent = serverIdent;
+      this.extIdNotesFactory = extIdNotesFactory;
+    }
+
+    @Override
+    public AccountsUpdate create(IdentifiedUser currentUser) {
+      return new AccountsUpdateNoteDbImpl(
+          repoManager,
+          gitRefUpdated,
+          Optional.of(currentUser),
+          allUsersName,
+          externalIds,
+          extIdNotesFactory,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          serverIdent,
+          createPersonIdent(serverIdent, Optional.of(currentUser)),
+          AccountsUpdateNoteDbImpl::doNothing,
+          AccountsUpdateNoteDbImpl::doNothing);
+    }
+
+    @Override
+    public AccountsUpdate createWithServerIdent() {
+      return new AccountsUpdateNoteDbImpl(
+          repoManager,
+          gitRefUpdated,
+          Optional.empty(),
+          allUsersName,
+          externalIds,
+          extIdNotesFactory,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          serverIdent,
+          createPersonIdent(serverIdent, Optional.empty()),
+          AccountsUpdateNoteDbImpl::doNothing,
+          AccountsUpdateNoteDbImpl::doNothing);
+    }
+  }
+
+  @Singleton
+  public static class Factory extends AbstractFactory {
+    @Inject
+    Factory(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        ExternalIds externalIds,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        @GerritPersonIdent PersonIdent serverIdent,
+        ExternalIdNotes.Factory extIdNotesFactory) {
+      super(
+          repoManager,
+          gitRefUpdated,
+          allUsersName,
+          externalIds,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          serverIdent,
+          extIdNotesFactory);
+    }
+  }
+
+  @Singleton
+  public static class FactoryNoReindex extends AbstractFactory {
+    @Inject
+    FactoryNoReindex(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        ExternalIds externalIds,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        @GerritPersonIdent PersonIdent serverIdent,
+        ExternalIdNotes.FactoryNoReindex extIdNotesFactory) {
+      super(
+          repoManager,
+          gitRefUpdated,
+          allUsersName,
+          externalIds,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          serverIdent,
+          extIdNotesFactory);
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final Optional<IdentifiedUser> currentUser;
+  private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
+
+  private final ExternalIdNotes.ExternalIdNotesLoader extIdNotesFactory;
+  private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+  private final RetryHelper retryHelper;
+  private final PersonIdent committerIdent;
+  private final PersonIdent authorIdent;
+
+  /** Invoked after reading the account config. */
+  private final Runnable afterReadRevision;
+
+  /** Invoked after updating the account but before committing the changes. */
+  private final Runnable beforeCommit;
+
+  /** Single instance that accumulates updates from the batch. */
+  @Nullable private ExternalIdNotes externalIdNotes;
+
+  @VisibleForTesting
+  public AccountsUpdateNoteDbImpl(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      Optional<IdentifiedUser> currentUser,
+      AllUsersName allUsersName,
+      ExternalIds externalIds,
+      ExternalIdNotes.ExternalIdNotesLoader extIdNotesFactory,
+      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+      RetryHelper retryHelper,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      Runnable afterReadRevision,
+      Runnable beforeCommit) {
+    this.repoManager = requireNonNull(repoManager, "repoManager");
+    this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
+    this.currentUser = currentUser;
+    this.allUsersName = requireNonNull(allUsersName, "allUsersName");
+    this.externalIds = requireNonNull(externalIds, "externalIds");
+    this.extIdNotesFactory = extIdNotesFactory;
+    this.metaDataUpdateInternalFactory =
+        requireNonNull(metaDataUpdateInternalFactory, "metaDataUpdateInternalFactory");
+    this.retryHelper = requireNonNull(retryHelper, "retryHelper");
+    this.committerIdent = requireNonNull(committerIdent, "committerIdent");
+    this.authorIdent = requireNonNull(authorIdent, "authorIdent");
+    this.afterReadRevision = requireNonNull(afterReadRevision, "afterReadRevision");
+    this.beforeCommit = requireNonNull(beforeCommit, "beforeCommit");
+  }
+
+  private static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
+    return (a, u) -> consumer.accept(u);
+  }
+
+  private static PersonIdent createPersonIdent(
+      PersonIdent serverIdent, Optional<IdentifiedUser> user) {
+    return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
+  }
+
+  @Override
+  public AccountState insert(
+      String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
+      throws IOException, ConfigInvalidException {
+    return insert(message, accountId, fromConsumer(init));
+  }
+
+  @Override
+  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
+      throws IOException, ConfigInvalidException {
+    return execute(
+            ImmutableList.of(
+                repo -> {
+                  AccountConfig accountConfig = read(repo, accountId);
+                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
+                  AccountState accountState = AccountState.forAccount(account);
+                  AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+                  init.configure(accountState, deltaBuilder);
+
+                  AccountDelta accountDelta = deltaBuilder.build();
+                  accountConfig.setAccountDelta(accountDelta);
+                  externalIdNotes =
+                      createExternalIdNotes(
+                          repo, accountConfig.getExternalIdsRev(), accountId, accountDelta);
+                  CachedPreferences defaultPreferences =
+                      CachedPreferences.fromConfig(
+                          VersionedDefaultPreferences.get(repo, allUsersName));
+
+                  return new UpdatedAccount(message, accountConfig, defaultPreferences, true);
+                }))
+        .get(0)
+        .get();
+  }
+
+  @Override
+  public Optional<AccountState> update(
+      String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
+      throws IOException, ConfigInvalidException {
+    return update(message, accountId, fromConsumer(update));
+  }
+
+  @Override
+  public Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
+      throws LockFailureException, IOException, ConfigInvalidException {
+    return updateBatch(
+            ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
+        .get(0);
+  }
+
+  @Override
+  public void delete(String message, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    ImmutableSet<ExternalId> accountExternalIds = externalIds.byAccount(accountId);
+    Consumer<AccountDelta.Builder> delta =
+        deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
+    update(message, accountId, delta);
+  }
+
+  private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
+    return repo -> {
+      AccountConfig accountConfig = read(repo, updateArguments.accountId);
+      CachedPreferences defaultPreferences =
+          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+      Optional<AccountState> accountState =
+          AccountsNoteDbImpl.getFromAccountConfig(externalIds, accountConfig, defaultPreferences);
+      if (!accountState.isPresent()) {
+        return null;
+      }
+
+      AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
+
+      AccountDelta delta = deltaBuilder.build();
+      ExternalIdNotes.checkSameAccount(
+          Iterables.concat(
+              delta.getCreatedExternalIds(),
+              delta.getUpdatedExternalIds(),
+              delta.getDeletedExternalIds()),
+          updateArguments.accountId);
+
+      if (delta.hasExternalIdUpdates()) {
+        // Only load the externalIds if they are going to be updated
+        // This makes e.g. preferences updates faster.
+        if (externalIdNotes == null) {
+          externalIdNotes =
+              extIdNotesFactory.load(
+                  repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
+        }
+        externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
+        externalIdNotes.upsert(delta.getUpdatedExternalIds());
+      }
+
+      if (delta.getShouldDeleteAccount().orElse(false)) {
+        return new DeletedAccount(updateArguments.message, accountConfig.getRefName());
+      }
+
+      accountConfig.setAccountDelta(delta);
+      CachedPreferences cachedDefaultPreferences =
+          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+      return new UpdatedAccount(
+          updateArguments.message, accountConfig, cachedDefaultPreferences, false);
+    };
+  }
+
+  @Override
+  public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
+      throws IOException, ConfigInvalidException {
+    checkArgument(
+        updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
+        "updates must all be for different accounts");
+    return execute(updates.stream().map(this::createExecutableUpdate).collect(toList()));
+  }
+
+  private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
+    afterReadRevision.run();
+    return accountConfig;
+  }
+
+  private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
+      throws IOException, ConfigInvalidException {
+    try (RefUpdateContext ctx = RefUpdateContext.open(ACCOUNTS_UPDATE)) {
+      List<Optional<AccountState>> accountState = new ArrayList<>();
+      List<UpdatedAccount> updatedAccounts = new ArrayList<>();
+      executeWithRetry(
+          () -> {
+
+            // Reset state for retry.
+            externalIdNotes = null;
+            accountState.clear();
+            updatedAccounts.clear();
+            try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+              for (ExecutableUpdate executableUpdate : executableUpdates) {
+                updatedAccounts.add(executableUpdate.execute(allUsersRepo));
+              }
+              commit(
+                  allUsersRepo,
+                  updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
+              for (UpdatedAccount ua : updatedAccounts) {
+                accountState.add(
+                    ua == null || ua.deleted ? Optional.empty() : ua.getAccountState());
+              }
+            }
+            return null;
+          });
+
+      return ImmutableList.copyOf(accountState);
+    }
+  }
+
+  private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
+    try {
+      retryHelper.accountUpdate("updateAccount", action).call();
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, IOException.class);
+      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
+      throw new StorageException(e);
+    }
+  }
+
+  private ExternalIdNotes createExternalIdNotes(
+      Repository allUsersRepo, Optional<ObjectId> rev, Account.Id accountId, AccountDelta update)
+      throws IOException, ConfigInvalidException, DuplicateKeyException {
+    ExternalIdNotes.checkSameAccount(
+        Iterables.concat(
+            update.getCreatedExternalIds(),
+            update.getUpdatedExternalIds(),
+            update.getDeletedExternalIds()),
+        accountId);
+
+    ExternalIdNotes extIdNotes =
+        extIdNotesFactory.load(allUsersRepo, rev.orElse(ObjectId.zeroId()));
+    extIdNotes.replace(update.getDeletedExternalIds(), update.getCreatedExternalIds());
+    extIdNotes.upsert(update.getUpdatedExternalIds());
+    return extIdNotes;
+  }
+
+  private void commit(Repository allUsersRepo, List<UpdatedAccount> updatedAccounts)
+      throws IOException {
+    if (updatedAccounts.isEmpty()) {
+      return;
+    }
+
+    beforeCommit.run();
+
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    Set<Account.Id> accountsToSkipForReindex = new HashSet<>();
+    //  External ids may be not updated if:
+    //  * externalIdNotes is not loaded  (there were no externalId updates in the delta)
+    //  * new revCommit is identical to the previous externalId tip
+    boolean externalIdsUpdated = false;
+    if (externalIdNotes != null) {
+      String externalIdUpdateMessage =
+          updatedAccounts.size() == 1
+              ? Iterables.getOnlyElement(updatedAccounts).message
+              : "Batch update for " + updatedAccounts.size() + " accounts";
+      ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
+      // These update the same ref, so they need to be stacked on top of one another using the same
+      // ExternalIdNotes instance.
+      RevCommit revCommit =
+          commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+      externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
+    }
+    for (UpdatedAccount updatedAccount : updatedAccounts) {
+      if (updatedAccount.deleted) {
+        RefUpdate ru = RefUpdateUtil.deleteChecked(allUsersRepo, updatedAccount.refName);
+        gitRefUpdated.fire(allUsersName, ru, ReceiveCommand.Type.DELETE, null);
+        accountsToSkipForReindex.add(Account.Id.fromRef(updatedAccount.refName));
+        continue;
+      }
+      // These updates are all for different refs (because batches never update the same account
+      // more than once), so there can be multiple commits in the same batch, all with the same base
+      // revision in their AccountConfig.
+      // We allow empty commits:
+      // 1) When creating a new account, 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.
+      // 2) When updating "refs/meta/external-ids", so that refs/users/* meta ref is updated too.
+      // This allows to schedule reindexing of account transactionally on refs/users/* meta
+      // updates.
+      boolean allowEmptyCommit = externalIdsUpdated || updatedAccount.created;
+      commitAccountConfig(
+          updatedAccount.message,
+          allUsersRepo,
+          batchRefUpdate,
+          updatedAccount.accountConfig,
+          allowEmptyCommit);
+    }
+
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+
+    if (externalIdsUpdated) {
+      accountsToSkipForReindex.addAll(getUpdatedAccountIds(batchRefUpdate));
+      extIdNotesFactory.updateExternalIdCacheAndMaybeReindexAccounts(
+          externalIdNotes, accountsToSkipForReindex);
+    }
+
+    gitRefUpdated.fire(
+        allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
+  }
+
+  private static Set<Account.Id> getUpdatedAccountIds(BatchRefUpdate batchRefUpdate) {
+    return batchRefUpdate.getCommands().stream()
+        .map(c -> Account.Id.fromRef(c.getRefName()))
+        .filter(Objects::nonNull)
+        .collect(toSet());
+  }
+
+  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 RevCommit commitExternalIdUpdates(
+      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
+    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
+      return externalIdNotes.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;
+  }
+
+  private static void doNothing() {}
+
+  @FunctionalInterface
+  private interface ExecutableUpdate {
+    UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+  }
+
+  private class UpdatedAccount {
+    final String message;
+    final AccountConfig accountConfig;
+    final CachedPreferences defaultPreferences;
+    final String refName;
+    final boolean created;
+    final boolean deleted;
+
+    UpdatedAccount(
+        String message,
+        AccountConfig accountConfig,
+        CachedPreferences defaultPreferences,
+        boolean created) {
+      this(
+          message,
+          requireNonNull(accountConfig),
+          defaultPreferences,
+          accountConfig.getRefName(),
+          created,
+          false);
+    }
+
+    protected UpdatedAccount(
+        String message,
+        AccountConfig accountConfig,
+        CachedPreferences defaultPreferences,
+        String refName,
+        boolean created,
+        boolean deleted) {
+      checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
+      this.message = requireNonNull(message);
+      this.accountConfig = accountConfig;
+      this.defaultPreferences = defaultPreferences;
+      this.refName = refName;
+      this.created = created;
+      this.deleted = deleted;
+    }
+
+    Optional<AccountState> getAccountState() throws IOException {
+      return AccountsNoteDbImpl.getFromAccountConfig(
+          externalIds, accountConfig, externalIdNotes, defaultPreferences);
+    }
+  }
+
+  private class DeletedAccount extends UpdatedAccount {
+    DeletedAccount(String message, String refName) {
+      super(message, null, null, refName, false, true);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 83b7565..fb450ea 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -223,7 +223,6 @@
     change.setWorkInProgress(workInProgress);
     change.setReviewStarted(!workInProgress);
     change.setRevertOf(revertOf);
-    change.setCustomKeyedValues(customKeyedValues);
     return change;
   }
 
@@ -475,12 +474,12 @@
     } catch (ValidationException ex) {
       throw new BadRequestException(ex.getMessage());
     }
-    if (change.getCustomKeyedValues() != null) {
+    if (customKeyedValues != null) {
       try {
-        if (change.getCustomKeyedValues().entrySet().size() > MAX_CUSTOM_KEYED_VALUES) {
+        if (customKeyedValues.entrySet().size() > MAX_CUSTOM_KEYED_VALUES) {
           throw new ValidationException("Too many custom keyed values");
         }
-        for (Map.Entry<String, String> entry : change.getCustomKeyedValues().entrySet()) {
+        for (Map.Entry<String, String> entry : customKeyedValues.entrySet()) {
           update.addCustomKeyedValue(entry.getKey(), entry.getValue());
         }
       } catch (ValidationException ex) {
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index d54d0f2..cb321e1 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -108,7 +108,6 @@
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
-import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
 import com.google.gerrit.server.account.storage.notedb.AccountNoteDbStorageModule;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
@@ -511,7 +510,6 @@
     bind(ReloadPluginListener.class)
         .annotatedWith(UniqueAnnotations.create())
         .to(PluginConfigFactory.class);
-    DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
 
     bind(AttentionSetObserver.class);
   }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
index af93b8a..d0b4470 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.inject.Provides;
 import com.google.inject.multibindings.OptionalBinder;
 
@@ -112,23 +111,22 @@
     post(ACCOUNT_KIND, "drafts:delete").to(DeleteDraftComments.class);
 
     // The gpgkeys REST endpoints are bound via GpgApiModule.
-
-    factory(AccountsUpdate.Factory.class);
   }
 
   @Provides
   @ServerInitiated
   AccountsUpdate provideServerInitiatedAccountsUpdate(
-      AccountsUpdate.Factory accountsUpdateFactory, ExternalIdNotes.Factory extIdNotesFactory) {
-    return accountsUpdateFactory.createWithServerIdent(extIdNotesFactory);
+      @AccountsUpdate.AccountsUpdateLoader.WithReindex
+          AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory) {
+    return accountsUpdateFactory.createWithServerIdent();
   }
 
   @Provides
   @UserInitiated
   AccountsUpdate provideUserInitiatedAccountsUpdate(
-      AccountsUpdate.Factory accountsUpdateFactory,
-      IdentifiedUser currentUser,
-      ExternalIdNotes.Factory extIdNotesFactory) {
-    return accountsUpdateFactory.create(currentUser, extIdNotesFactory);
+      @AccountsUpdate.AccountsUpdateLoader.WithReindex
+          AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory,
+      IdentifiedUser currentUser) {
+    return accountsUpdateFactory.create(currentUser);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 2c65019..3fff964 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -146,6 +146,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.storage.notedb.AccountsUpdateNoteDbImpl;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -2170,26 +2171,8 @@
     String status = "happy";
     String fullName = "Foo";
     AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
-    PersonIdent ident = serverIdent.get();
     AccountsUpdate update =
-        new AccountsUpdate(
-            repoManager,
-            gitReferenceUpdated,
-            Optional.empty(),
-            allUsers,
-            externalIds,
-            metaDataUpdateInternalFactory,
-            new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                exceptionHooks,
-                r -> r.withBlockStrategy(noSleepBlockStrategy)),
-            extIdNotesFactory,
-            ident,
-            ident,
+        getAccountsUpdate(
             () -> {
               if (!doneBgUpdate.getAndSet(true)) {
                 try {
@@ -2226,28 +2209,8 @@
     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,
-            Optional.empty(),
-            allUsers,
-            externalIds,
-            metaDataUpdateInternalFactory,
-            new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                exceptionHooks,
-                r ->
-                    r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
-                        .withBlockStrategy(noSleepBlockStrategy)),
-            extIdNotesFactory,
-            ident,
-            ident,
+        getAccountsUpdate(
             () -> {
               try {
                 accountsUpdateProvider
@@ -2260,7 +2223,17 @@
                 // Ignore, the expected exception is asserted later
               }
             },
-            Runnables.doNothing());
+            Runnables.doNothing(),
+            new RetryHelper(
+                cfg,
+                retryMetrics,
+                null,
+                null,
+                null,
+                exceptionHooks,
+                r ->
+                    r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
+                        .withBlockStrategy(noSleepBlockStrategy)));
     assertThat(bgCounter.get()).isEqualTo(0);
     AccountInfo accountInfo = gApi.accounts().id(admin.id().get()).get();
     assertThat(accountInfo.status).isNull();
@@ -2286,26 +2259,8 @@
 
     AtomicInteger bgCounterA1 = new AtomicInteger(0);
     AtomicInteger bgCounterA2 = new AtomicInteger(0);
-    PersonIdent ident = serverIdent.get();
     AccountsUpdate update =
-        new AccountsUpdate(
-            repoManager,
-            gitReferenceUpdated,
-            Optional.empty(),
-            allUsers,
-            externalIds,
-            metaDataUpdateInternalFactory,
-            new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                exceptionHooks,
-                r -> r.withBlockStrategy(noSleepBlockStrategy)),
-            extIdNotesFactory,
-            ident,
-            ident,
+        getAccountsUpdate(
             Runnables.doNothing(),
             () -> {
               try {
@@ -2360,27 +2315,9 @@
 
     AtomicInteger bgCounterA1 = new AtomicInteger(0);
     AtomicInteger bgCounterA2 = new AtomicInteger(0);
-    PersonIdent ident = serverIdent.get();
     ExternalId extIdA2 = externalIdFactory.create("foo", "A-2", accountId);
     AccountsUpdate update =
-        new AccountsUpdate(
-            repoManager,
-            gitReferenceUpdated,
-            Optional.empty(),
-            allUsers,
-            externalIds,
-            metaDataUpdateInternalFactory,
-            new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                exceptionHooks,
-                r -> r.withBlockStrategy(noSleepBlockStrategy)),
-            extIdNotesFactory,
-            ident,
-            ident,
+        getAccountsUpdate(
             Runnables.doNothing(),
             () -> {
               try {
@@ -3681,6 +3618,37 @@
         "login?account_id=" + accountId, HttpServletResponse.SC_MOVED_TEMPORARILY);
   }
 
+  private AccountsUpdate getAccountsUpdate(Runnable afterReadRevision, Runnable beforeCommit) {
+    return getAccountsUpdate(
+        afterReadRevision,
+        beforeCommit,
+        new RetryHelper(
+            cfg,
+            retryMetrics,
+            null,
+            null,
+            null,
+            exceptionHooks,
+            r -> r.withBlockStrategy(noSleepBlockStrategy)));
+  }
+
+  private AccountsUpdate getAccountsUpdate(
+      Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
+    return new AccountsUpdateNoteDbImpl(
+        repoManager,
+        gitReferenceUpdated,
+        Optional.empty(),
+        allUsers,
+        externalIds,
+        extIdNotesFactory,
+        metaDataUpdateInternalFactory,
+        retryHelper,
+        serverIdent.get(),
+        serverIdent.get(),
+        afterReadRevision,
+        beforeCommit);
+  }
+
   private void httpGetAndAssertStatus(String urlPath, int expectedHttpStatus)
       throws ClientProtocolException, IOException {
     HttpGet httpGet = new HttpGet(canonicalWebUrl.get() + urlPath);
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index 296d801..bbf10bd 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
-import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
 import java.time.Instant;
 import org.junit.Test;
@@ -285,9 +284,6 @@
                 .put("currentPatchSetId", int.class)
                 .put("subject", String.class)
                 .put("topic", String.class)
-                .put(
-                    "customKeyedValues",
-                    new TypeLiteral<ImmutableMap<String, String>>() {}.getType())
                 .put("originalSubject", String.class)
                 .put("submissionId", String.class)
                 .put("isPrivate", boolean.class)
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 8d72a97..1f32073 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -327,7 +327,7 @@
       ${this.renderCCs()} ${this.renderProjectBranch()} ${this.renderParent()}
       ${this.renderMergedAs()} ${this.renderShowRevertCreatedAs()}
       ${this.renderTopic()} ${this.renderCherryPickOf()}
-      ${this.renderStrategy()} ${this.renderHashTags()}
+      ${this.renderRevertOf()} ${this.renderStrategy()} ${this.renderHashTags()}
       ${this.renderSubmitRequirements()} ${this.renderWeblinks()}
       <gr-endpoint-decorator name="change-metadata-item">
         <gr-endpoint-param
@@ -687,6 +687,23 @@
     </section>`;
   }
 
+  private renderRevertOf() {
+    if (!this.change?.revert_of) return nothing;
+    return html` <section class=${this.computeDisplayState(Metadata.REVERT_OF)}>
+      <span class="title">Revert of</span>
+      <span class="value">
+        <a
+          href=${createChangeUrl({
+            changeNum: this.change.revert_of,
+            repo: this.change.project,
+            usp: 'metadata',
+          })}
+          >${this.change.revert_of}</a
+        >
+      </span>
+    </section>`;
+  }
+
   private renderStrategy() {
     if (!changeIsOpen(this.change)) return nothing;
     return html`<section
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index c93cc97..97fa267 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -87,6 +87,10 @@
           background-color: var(--status-ready);
           color: var(--status-ready);
         }
+        :host(.revert) .chip {
+          background-color: var(--status-revert);
+          color: var(--status-revert);
+        }
         :host(.revert-created) .chip {
           background-color: var(--status-revert-created);
           color: var(--status-revert-created);
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row.ts
index f2742c7..1486154 100644
--- a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row.ts
@@ -315,8 +315,11 @@
     // For unified diff, this method will be called with number set to 0 for
     // the empty line number column for added/removed lines. This should not
     // be announced to the screenreader.
-    if (lineNumber === LOST || lineNumber <= 0) return undefined;
-
+    if (
+      lineNumber === LOST ||
+      (typeof lineNumber === 'number' && lineNumber <= 0)
+    )
+      return undefined;
     switch (line.type) {
       case GrDiffLineType.REMOVE:
         return `${lineNumber} removed`;
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection.ts
index a9ec6a2..407b403 100644
--- a/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection.ts
@@ -18,6 +18,7 @@
   getSideByLineEl,
   isThreadEl,
 } from '../../diff/gr-diff/gr-diff-utils';
+import {getContentFromDiff} from '../../../utils/diff-util';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -34,15 +35,6 @@
   return side === Side.LEFT ? SelectionClass.LEFT : SelectionClass.RIGHT;
 }
 
-interface LinesCache {
-  left: string[] | null;
-  right: string[] | null;
-}
-
-function getNewCache(): LinesCache {
-  return {left: null, right: null};
-}
-
 export class GrDiffSelection {
   // visible for testing
   diff?: DiffInfo;
@@ -50,9 +42,6 @@
   // visible for testing
   diffTable?: HTMLElement;
 
-  // visible for testing
-  linesCache: LinesCache = getNewCache();
-
   init(diff: DiffInfo, diffTable: HTMLElement) {
     this.cleanup();
     this.diff = diff;
@@ -60,7 +49,6 @@
     this.diffTable.classList.add(SelectionClass.RIGHT);
     this.diffTable.addEventListener('copy', this.handleCopy);
     this.diffTable.addEventListener('mousedown', this.handleDown);
-    this.linesCache = getNewCache();
   }
 
   cleanup() {
@@ -161,6 +149,7 @@
    * @return The selected text.
    */
   getSelectedText(side: Side) {
+    if (!this.diff) return '';
     const sel = this.getSelection();
     if (!sel || sel.rangeCount !== 1) {
       return ''; // No multi-select support yet.
@@ -188,7 +177,8 @@
       if (endLineDataValue) endLineNum = Number(endLineDataValue);
     }
 
-    return this.getRangeFromDiff(
+    return getContentFromDiff(
+      this.diff,
       startLineNum,
       range.startOffset,
       endLineNum,
@@ -196,52 +186,4 @@
       side
     );
   }
-
-  /**
-   * Query the diff object for the selected lines.
-   */
-  getRangeFromDiff(
-    startLineNum: number,
-    startOffset: number,
-    endLineNum: number | undefined,
-    endOffset: number,
-    side: Side
-  ) {
-    const skipChunk = this.diff?.content.find(chunk => chunk.skip);
-    if (skipChunk) {
-      startLineNum -= skipChunk.skip!;
-      if (endLineNum) endLineNum -= skipChunk.skip!;
-    }
-    const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
-    if (lines.length) {
-      lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
-      lines[0] = lines[0].substring(startOffset);
-    }
-    return lines.join('\n');
-  }
-
-  /**
-   * Query the diff object for the lines from a particular side.
-   *
-   * @param side The side that is currently selected.
-   * @return An array of strings indexed by line number.
-   */
-  getDiffLines(side: Side): string[] {
-    if (this.linesCache[side]) {
-      return this.linesCache[side]!;
-    }
-    if (!this.diff) return [];
-    let lines: string[] = [];
-    for (const chunk of this.diff.content) {
-      if (chunk.ab) {
-        lines = lines.concat(chunk.ab);
-      } else if (side === Side.LEFT && chunk.a) {
-        lines = lines.concat(chunk.a);
-      } else if (side === Side.RIGHT && chunk.b) {
-        lines = lines.concat(chunk.b);
-      }
-    }
-    this.linesCache[side] = lines;
-    return lines;
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index bfd6a0d..0ac7516 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -312,7 +312,11 @@
     // For unified diff, this method will be called with number set to 0 for
     // the empty line number column for added/removed lines. This should not
     // be announced to the screenreader.
-    if (lineNumber === LOST || lineNumber <= 0) return undefined;
+    if (
+      lineNumber === LOST ||
+      (typeof lineNumber === 'number' && lineNumber <= 0)
+    )
+      return undefined;
 
     switch (line.type) {
       case GrDiffLineType.REMOVE:
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
index a790736..f403ef9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -18,6 +18,7 @@
   getSideByLineEl,
   isThreadEl,
 } from '../gr-diff/gr-diff-utils';
+import {getContentFromDiff} from '../../../utils/diff-util';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -34,15 +35,6 @@
   return side === Side.LEFT ? SelectionClass.LEFT : SelectionClass.RIGHT;
 }
 
-interface LinesCache {
-  left: string[] | null;
-  right: string[] | null;
-}
-
-function getNewCache(): LinesCache {
-  return {left: null, right: null};
-}
-
 export class GrDiffSelection {
   // visible for testing
   diff?: DiffInfo;
@@ -50,9 +42,6 @@
   // visible for testing
   diffTable?: HTMLElement;
 
-  // visible for testing
-  linesCache: LinesCache = getNewCache();
-
   init(diff: DiffInfo, diffTable: HTMLElement) {
     this.cleanup();
     this.diff = diff;
@@ -60,7 +49,6 @@
     this.diffTable.classList.add(SelectionClass.RIGHT);
     this.diffTable.addEventListener('copy', this.handleCopy);
     this.diffTable.addEventListener('mousedown', this.handleDown);
-    this.linesCache = getNewCache();
   }
 
   cleanup() {
@@ -161,6 +149,7 @@
    * @return The selected text.
    */
   getSelectedText(side: Side) {
+    if (!this.diff) return '';
     const sel = this.getSelection();
     if (!sel || sel.rangeCount !== 1) {
       return ''; // No multi-select support yet.
@@ -188,7 +177,8 @@
       if (endLineDataValue) endLineNum = Number(endLineDataValue);
     }
 
-    return this.getRangeFromDiff(
+    return getContentFromDiff(
+      this.diff,
       startLineNum,
       range.startOffset,
       endLineNum,
@@ -196,52 +186,4 @@
       side
     );
   }
-
-  /**
-   * Query the diff object for the selected lines.
-   */
-  getRangeFromDiff(
-    startLineNum: number,
-    startOffset: number,
-    endLineNum: number | undefined,
-    endOffset: number,
-    side: Side
-  ) {
-    const skipChunk = this.diff?.content.find(chunk => chunk.skip);
-    if (skipChunk) {
-      startLineNum -= skipChunk.skip!;
-      if (endLineNum) endLineNum -= skipChunk.skip!;
-    }
-    const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
-    if (lines.length) {
-      lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
-      lines[0] = lines[0].substring(startOffset);
-    }
-    return lines.join('\n');
-  }
-
-  /**
-   * Query the diff object for the lines from a particular side.
-   *
-   * @param side The side that is currently selected.
-   * @return An array of strings indexed by line number.
-   */
-  getDiffLines(side: Side): string[] {
-    if (this.linesCache[side]) {
-      return this.linesCache[side]!;
-    }
-    if (!this.diff) return [];
-    let lines: string[] = [];
-    for (const chunk of this.diff.content) {
-      if (chunk.ab) {
-        lines = lines.concat(chunk.ab);
-      } else if (side === Side.LEFT && chunk.a) {
-        lines = lines.concat(chunk.a);
-      } else if (side === Side.RIGHT && chunk.b) {
-        lines = lines.concat(chunk.b);
-      }
-    }
-    this.linesCache[side] = lines;
-    return lines;
-  }
 }
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 0503e4c..7742b1f 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -323,6 +323,7 @@
     --status-wip: #795548;
     --status-private: var(--purple-500);
     --status-conflict: var(--red-600);
+    --status-revert: var(--gray-900);
     --status-revert-created: #e64a19;
     --status-active: var(--blue-700);
     --status-ready: var(--pink-800);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index dc3d4e9..574e6c2 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -181,6 +181,7 @@
     --status-wip: #bcaaa4;
     --status-private: var(--purple-200);
     --status-conflict: var(--red-300);
+    --status-revert: var(--gray-200);
     --status-revert-created: #ff8a65;
     --status-active: var(--blue-400);
     --status-ready: var(--pink-500);
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 7a6610b..766b407 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -692,7 +692,11 @@
   MERGED = 'Merged',
   PRIVATE = 'Private',
   READY_TO_SUBMIT = 'Ready to submit',
+  /** This change is a revert of another change. */
+  REVERT = 'Revert',
+  /** A revert of this change was created. */
   REVERT_CREATED = 'Revert Created',
+  /** A revert of this change was submitted. */
   REVERT_SUBMITTED = 'Revert Submitted',
   WIP = 'WIP',
 }
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
index 9d3106d..8b208e4 100644
--- a/polygerrit-ui/app/utils/change-metadata-util.ts
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -22,6 +22,7 @@
   AUTHOR = 'Author',
   COMMITTER = 'Committer',
   CHERRY_PICK_OF = 'Cherry pick of',
+  REVERT_OF = 'Revert of',
 }
 
 export const DisplayRules = {
@@ -39,6 +40,7 @@
     Metadata.AUTHOR,
     Metadata.COMMITTER,
     Metadata.CHERRY_PICK_OF,
+    Metadata.REVERT_OF,
   ],
   ALWAYS_HIDE: [
     Metadata.PARENT,
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 7de5e7e..584a4b1 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -157,6 +157,9 @@
   options?: ChangeStatusesOptions
 ): ChangeStates[] {
   const states = [];
+  if (change.revert_of) {
+    states.push(ChangeStates.REVERT);
+  }
   if (change.status === ChangeStatus.MERGED) {
     if (options?.revertingChangeStatus === ChangeStatus.MERGED) {
       return [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED];
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index 1782d80..1d209f9 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -163,6 +163,19 @@
     assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]);
   });
 
+  test('Revert status', () => {
+    const change = {
+      ...createChange(),
+      revert_of: 123 as NumericChangeId,
+    };
+    assert.deepEqual(changeStatuses(change), [ChangeStates.REVERT]);
+    change.is_private = true;
+    assert.deepEqual(changeStatuses(change), [
+      ChangeStates.REVERT,
+      ChangeStates.PRIVATE,
+    ]);
+  });
+
   test('Open status with private and wip', () => {
     const change = {
       ...createChange(),
diff --git a/polygerrit-ui/app/utils/diff-util.ts b/polygerrit-ui/app/utils/diff-util.ts
index 1e5abf9..28c22e5 100644
--- a/polygerrit-ui/app/utils/diff-util.ts
+++ b/polygerrit-ui/app/utils/diff-util.ts
@@ -18,6 +18,38 @@
   }, 0);
 }
 
+function getDiffLines(diff: DiffInfo, side: Side): string[] {
+  let lines: string[] = [];
+  for (const chunk of diff.content) {
+    if (chunk.skip) {
+      lines = lines.concat(Array(chunk.skip).fill(''));
+    } else if (chunk.ab) {
+      lines = lines.concat(chunk.ab);
+    } else if (side === Side.LEFT && chunk.a) {
+      lines = lines.concat(chunk.a);
+    } else if (side === Side.RIGHT && chunk.b) {
+      lines = lines.concat(chunk.b);
+    }
+  }
+  return lines;
+}
+
+export function getContentFromDiff(
+  diff: DiffInfo,
+  startLineNum: number,
+  startOffset: number,
+  endLineNum: number | undefined,
+  endOffset: number,
+  side: Side
+) {
+  const lines = getDiffLines(diff, side).slice(startLineNum - 1, endLineNum);
+  if (lines.length) {
+    lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
+    lines[0] = lines[0].substring(startOffset);
+  }
+  return lines.join('\n');
+}
+
 export function isFileUnchanged(diff: DiffInfo) {
   return !diff.content.some(
     content => (content.a && !content.common) || (content.b && !content.common)
diff --git a/polygerrit-ui/app/utils/diff-util_test.ts b/polygerrit-ui/app/utils/diff-util_test.ts
index dbab76d..2829209 100644
--- a/polygerrit-ui/app/utils/diff-util_test.ts
+++ b/polygerrit-ui/app/utils/diff-util_test.ts
@@ -4,10 +4,10 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {assert} from '@open-wc/testing';
-import {DiffInfo} from '../api/diff';
+import {DiffInfo, Side} from '../api/diff';
 import '../test/common-test-setup';
 import {createDiff} from '../test/test-data-generators';
-import {isFileUnchanged} from './diff-util';
+import {getContentFromDiff, isFileUnchanged} from './diff-util';
 
 suite('diff-util tests', () => {
   test('isFileUnchanged', () => {
@@ -41,4 +41,78 @@
     };
     assert.equal(isFileUnchanged(diff), true);
   });
+
+  suite('getContentFromDiff', () => {
+    test('one changed line', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [{a: ['abcd']}, {b: ['wxyz']}],
+      };
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.LEFT), 'bc');
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.RIGHT), 'xy');
+    });
+
+    test('one common line', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [{ab: ['abcd']}],
+      };
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.LEFT), 'bc');
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.RIGHT), 'bc');
+    });
+
+    test('multiple lines', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {a: ['l1-asdf', 'l2-asdf']},
+          {b: ['r1-wxyz']},
+          {ab: ['l3-r2-qwer', 'l4-r3-uiop']},
+          {b: ['r4-hjkl']},
+          {ab: ['l5-r5-bnm,']},
+        ],
+      };
+      assert.equal(
+        getContentFromDiff(diff, 1, 0, 5, 10, Side.LEFT),
+        'l1-asdf\nl2-asdf\nl3-r2-qwer\nl4-r3-uiop\nl5-r5-bnm,'
+      );
+      assert.equal(
+        getContentFromDiff(diff, 1, 0, 5, 10, Side.RIGHT),
+        'r1-wxyz\nl3-r2-qwer\nl4-r3-uiop\nr4-hjkl\nl5-r5-bnm,'
+      );
+    });
+
+    test('one skip chunk', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [{skip: 5}, {ab: ['abcd']}],
+      };
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.LEFT), '');
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.RIGHT), '');
+      assert.equal(getContentFromDiff(diff, 6, 1, 6, 3, Side.LEFT), 'bc');
+      assert.equal(getContentFromDiff(diff, 6, 1, 6, 3, Side.RIGHT), 'bc');
+    });
+
+    test('multiple skip chunks', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {skip: 5},
+          {ab: ['abcd']},
+          {skip: 5},
+          {ab: ['qwer']},
+          {skip: 5},
+          {ab: ['zxcv']},
+        ],
+      };
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.LEFT), '');
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.RIGHT), '');
+      assert.equal(getContentFromDiff(diff, 6, 1, 6, 3, Side.LEFT), 'bc');
+      assert.equal(getContentFromDiff(diff, 6, 1, 6, 3, Side.RIGHT), 'bc');
+      assert.equal(getContentFromDiff(diff, 12, 1, 12, 3, Side.LEFT), 'we');
+      assert.equal(getContentFromDiff(diff, 12, 1, 12, 3, Side.RIGHT), 'we');
+      assert.equal(getContentFromDiff(diff, 18, 1, 18, 3, Side.LEFT), 'xc');
+      assert.equal(getContentFromDiff(diff, 18, 1, 18, 3, Side.RIGHT), 'xc');
+    });
+  });
 });