Merge changes from topic "gr-dialog-to-ts"

* changes:
  Move gr-dialog to typescript
  Rename files to preserve history
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 2d62608..07aab3b 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -30,6 +30,9 @@
 import com.google.gerrit.acceptance.config.GlobalPluginConfigs;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerPatchsetOperationsImpl;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -506,6 +509,8 @@
             bind(GroupOperations.class).to(GroupOperationsImpl.class);
             bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
             bind(RequestScopeOperations.class).to(RequestScopeOperationsImpl.class);
+            bind(ChangeOperations.class).to(ChangeOperationsImpl.class);
+            factory(PerPatchsetOperationsImpl.Factory.class);
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
             install(new NoSshModule());
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
new file mode 100644
index 0000000..cc96d5b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+
+/**
+ * An aggregation of operations on changes for test purposes.
+ *
+ * <p>To execute the operations, no Gerrit permissions are necessary.
+ *
+ * <p><strong>Note:</strong> This interface is not implemented using the REST or extension API.
+ * Hence, it cannot be used for testing those APIs.
+ */
+public interface ChangeOperations {
+
+  /**
+   * Starts the fluent chain for querying or modifying a change. Please see the methods of {@link
+   * PerChangeOperations} for details on possible operations.
+   *
+   * @return an aggregation of operations on a specific change
+   */
+  PerChangeOperations change(Change.Id changeId);
+
+  /**
+   * Starts the fluent chain to create a change. The returned builder can be used to specify the
+   * attributes of the new change. To create the change for real, {@link
+   * TestChangeCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * Change.Id createdChangeId = changeOperations
+   *     .newChange()
+   *     .file("file1")
+   *     .content("Line 1\nLine2\n")
+   *     .create();
+   * </pre>
+   *
+   * <p><strong>Note:</strong> There must be at least one existing user and repository.
+   *
+   * @return a builder to create the new change
+   */
+  TestChangeCreation.Builder newChange();
+
+  /** An aggregation of methods on a specific change. */
+  interface PerChangeOperations {
+
+    /**
+     * Checks whether the change exists.
+     *
+     * @return {@code true} if the change exists
+     */
+    boolean exists();
+
+    /**
+     * Retrieves the change.
+     *
+     * <p><strong>Note:</strong> This call will fail with an exception if the requested change
+     * doesn't exist. If you want to check for the existence of a change, use {@link #exists()}
+     * instead.
+     *
+     * @return the corresponding {@code TestChange}
+     */
+    TestChange get();
+
+    /**
+     * Starts the fluent chain to create a new patchset. The returned builder can be used to specify
+     * the attributes of the new patchset. To create the patchset for real, {@link
+     * TestPatchsetCreation.Builder#create()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * PatchSet.Id createdPatchsetId = changeOperations
+     *     .change(changeId)
+     *     .newPatchset()
+     *     .file("file1")
+     *     .content("Line 1\nLine2\n")
+     *     .create();
+     * </pre>
+     *
+     * @return builder to create a new patchset
+     */
+    TestPatchsetCreation.Builder newPatchset();
+
+    /**
+     * Starts the fluent chain for querying or modifying a patchset. Please see the methods of
+     * {@link PerPatchsetOperations} for details on possible operations.
+     *
+     * @return an aggregation of operations on a specific patchset
+     */
+    PerPatchsetOperations patchset(PatchSet.Id patchsetId);
+
+    /**
+     * Like {@link #patchset(PatchSet.Id)} but for the current patchset.
+     *
+     * @return an aggregation of operations on a specific patchset
+     */
+    PerPatchsetOperations currentPatchset();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
new file mode 100644
index 0000000..3bd6475
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -0,0 +1,412 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.edit.tree.TreeCreator;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+
+/**
+ * The implementation of {@link ChangeOperations}.
+ *
+ * <p>There is only one implementation of {@link ChangeOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class ChangeOperationsImpl implements ChangeOperations {
+  private final Sequences seq;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final PatchSetInserter.Factory patchsetInserterFactory;
+  private final GitRepositoryManager repositoryManager;
+  private final AccountResolver resolver;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final PersonIdent serverIdent;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ProjectCache projectCache;
+  private final ChangeFinder changeFinder;
+  private final PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory;
+
+  @Inject
+  public ChangeOperationsImpl(
+      Sequences seq,
+      ChangeInserter.Factory changeInserterFactory,
+      PatchSetInserter.Factory patchsetInserterFactory,
+      GitRepositoryManager repositoryManager,
+      AccountResolver resolver,
+      IdentifiedUser.GenericFactory userFactory,
+      @GerritPersonIdent PersonIdent serverIdent,
+      BatchUpdate.Factory batchUpdateFactory,
+      ProjectCache projectCache,
+      ChangeFinder changeFinder,
+      PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory) {
+    this.seq = seq;
+    this.changeInserterFactory = changeInserterFactory;
+    this.patchsetInserterFactory = patchsetInserterFactory;
+    this.repositoryManager = repositoryManager;
+    this.resolver = resolver;
+    this.userFactory = userFactory;
+    this.serverIdent = serverIdent;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.projectCache = projectCache;
+    this.changeFinder = changeFinder;
+    this.perPatchsetOperationsFactory = perPatchsetOperationsFactory;
+  }
+
+  @Override
+  public PerChangeOperations change(Change.Id changeId) {
+    return new PerChangeOperationsImpl(changeId);
+  }
+
+  @Override
+  public TestChangeCreation.Builder newChange() {
+    return TestChangeCreation.builder(this::createChange);
+  }
+
+  private Change.Id createChange(TestChangeCreation changeCreation) throws Exception {
+    Change.Id changeId = Change.id(seq.nextChangeId());
+    Project.NameKey project = getTargetProject(changeCreation);
+
+    try (Repository repository = repositoryManager.openRepository(project);
+        ObjectInserter objectInserter = repository.newObjectInserter();
+        RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+      Timestamp now = TimeUtil.nowTs();
+      IdentifiedUser changeOwner = getChangeOwner(changeCreation);
+      PersonIdent authorAndCommitter =
+          changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+      ObjectId commitId =
+          createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
+
+      String refName = RefNames.fullName(changeCreation.branch());
+      ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+
+      try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+        batchUpdate.setRepository(repository, revWalk, objectInserter);
+        batchUpdate.insertChange(inserter);
+        batchUpdate.execute();
+      }
+      return changeId;
+    }
+  }
+
+  private Project.NameKey getTargetProject(TestChangeCreation changeCreation) {
+    if (changeCreation.project().isPresent()) {
+      return changeCreation.project().get();
+    }
+
+    return getArbitraryProject();
+  }
+
+  private Project.NameKey getArbitraryProject() {
+    Project.NameKey allProjectsName = projectCache.getAllProjects().getNameKey();
+    Project.NameKey allUsersName = projectCache.getAllUsers().getNameKey();
+    Optional<Project.NameKey> arbitraryProject =
+        projectCache.all().stream()
+            .filter(
+                name ->
+                    !Objects.equals(name, allProjectsName) && !Objects.equals(name, allUsersName))
+            .findFirst();
+    checkState(
+        arbitraryProject.isPresent(),
+        "At least one repository must be available on the Gerrit server");
+    return arbitraryProject.get();
+  }
+
+  private IdentifiedUser getChangeOwner(TestChangeCreation changeCreation)
+      throws IOException, ConfigInvalidException {
+    if (changeCreation.owner().isPresent()) {
+      return userFactory.create(changeCreation.owner().get());
+    }
+
+    return getArbitraryUser();
+  }
+
+  private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException {
+    ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet();
+    checkState(
+        !foundAccounts.isEmpty(),
+        "At least one user account must be available on the Gerrit server");
+    return userFactory.create(foundAccounts.iterator().next());
+  }
+
+  private ObjectId createCommit(
+      Repository repository,
+      RevWalk revWalk,
+      ObjectInserter objectInserter,
+      TestChangeCreation changeCreation,
+      PersonIdent authorAndCommitter)
+      throws IOException, BadRequestException {
+    Optional<ObjectId> branchTip = getTip(repository, changeCreation.branch());
+
+    ObjectId tree =
+        createNewTree(
+            repository,
+            revWalk,
+            branchTip.orElse(ObjectId.zeroId()),
+            changeCreation.treeModifications());
+
+    String commitMessage = correctCommitMessage(changeCreation.commitMessage());
+
+    ImmutableList<ObjectId> parentCommitIds = Streams.stream(branchTip).collect(toImmutableList());
+    return createCommit(
+        objectInserter,
+        tree,
+        parentCommitIds,
+        authorAndCommitter,
+        authorAndCommitter,
+        commitMessage);
+  }
+
+  private Optional<ObjectId> getTip(Repository repository, String branch) throws IOException {
+    Optional<Ref> ref = Optional.ofNullable(repository.findRef(branch));
+    return ref.map(Ref::getObjectId);
+  }
+
+  private static ObjectId createNewTree(
+      Repository repository,
+      RevWalk revWalk,
+      ObjectId baseCommitId,
+      ImmutableList<TreeModification> treeModifications)
+      throws IOException {
+    TreeCreator treeCreator = getTreeCreator(revWalk, baseCommitId);
+    treeCreator.addTreeModifications(treeModifications);
+    return treeCreator.createNewTreeAndGetId(repository);
+  }
+
+  private static TreeCreator getTreeCreator(RevWalk revWalk, ObjectId baseCommitId)
+      throws IOException {
+    if (ObjectId.zeroId().equals(baseCommitId)) {
+      return TreeCreator.basedOnEmptyTree();
+    }
+    RevCommit baseCommit = revWalk.parseCommit(baseCommitId);
+    return TreeCreator.basedOn(baseCommit);
+  }
+
+  private String correctCommitMessage(String desiredCommitMessage) throws BadRequestException {
+    String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
+
+    if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
+      ObjectId id = CommitMessageUtil.generateChangeId();
+      commitMessage = ChangeIdUtil.insertId(commitMessage, id);
+    }
+
+    return commitMessage;
+  }
+
+  private ObjectId createCommit(
+      ObjectInserter objectInserter,
+      ObjectId tree,
+      ImmutableList<ObjectId> parentCommitIds,
+      PersonIdent author,
+      PersonIdent committer,
+      String commitMessage)
+      throws IOException {
+    CommitBuilder builder = new CommitBuilder();
+    builder.setTreeId(tree);
+    builder.setParentIds(parentCommitIds);
+    builder.setAuthor(author);
+    builder.setCommitter(committer);
+    builder.setMessage(commitMessage);
+    ObjectId newCommitId = objectInserter.insert(builder);
+    objectInserter.flush();
+    return newCommitId;
+  }
+
+  private ChangeInserter getChangeInserter(Change.Id changeId, String refName, ObjectId commitId) {
+    ChangeInserter inserter = changeInserterFactory.create(changeId, commitId, refName);
+    inserter.setMessage(String.format("Uploaded patchset %d.", inserter.getPatchSetId().get()));
+    return inserter;
+  }
+
+  private class PerChangeOperationsImpl implements PerChangeOperations {
+
+    private final Change.Id changeId;
+
+    public PerChangeOperationsImpl(Change.Id changeId) {
+      this.changeId = changeId;
+    }
+
+    @Override
+    public boolean exists() {
+      return changeFinder.findOne(changeId).isPresent();
+    }
+
+    @Override
+    public TestChange get() {
+      return toTestChange(getChangeNotes().getChange());
+    }
+
+    private ChangeNotes getChangeNotes() {
+      Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+      checkState(changeNotes.isPresent(), "Tried to get non-existing test change.");
+      return changeNotes.get();
+    }
+
+    private TestChange toTestChange(Change change) {
+      return TestChange.builder()
+          .numericChangeId(change.getId())
+          .changeId(change.getKey().get())
+          .build();
+    }
+
+    @Override
+    public TestPatchsetCreation.Builder newPatchset() {
+      return TestPatchsetCreation.builder(this::createPatchset);
+    }
+
+    private PatchSet.Id createPatchset(TestPatchsetCreation patchsetCreation)
+        throws IOException, RestApiException, UpdateException {
+      ChangeNotes changeNotes = getChangeNotes();
+      Project.NameKey project = changeNotes.getProjectName();
+      try (Repository repository = repositoryManager.openRepository(project);
+          ObjectInserter objectInserter = repository.newObjectInserter();
+          RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+        Timestamp now = TimeUtil.nowTs();
+        ObjectId newPatchsetCommit =
+            createPatchsetCommit(
+                repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
+
+        PatchSet.Id patchsetId =
+            ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
+        PatchSetInserter patchSetInserter =
+            getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
+
+        IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner());
+        try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+          batchUpdate.setRepository(repository, revWalk, objectInserter);
+          batchUpdate.addOp(changeId, patchSetInserter);
+          batchUpdate.execute();
+        }
+        return patchsetId;
+      }
+    }
+
+    private ObjectId createPatchsetCommit(
+        Repository repository,
+        RevWalk revWalk,
+        ObjectInserter objectInserter,
+        ChangeNotes changeNotes,
+        TestPatchsetCreation patchsetCreation,
+        Timestamp now)
+        throws IOException {
+      ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
+      RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
+
+      ObjectId tree =
+          createNewTree(
+              repository, revWalk, oldPatchsetCommitId, patchsetCreation.treeModifications());
+
+      String commitMessage = oldPatchsetCommit.getFullMessage();
+
+      ImmutableList<ObjectId> parentCommitIds = getParents(oldPatchsetCommit);
+      PersonIdent author = getAuthor(oldPatchsetCommit);
+      PersonIdent committer = getCommitter(oldPatchsetCommit, now);
+      return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
+    }
+
+    private PersonIdent getAuthor(RevCommit oldPatchsetCommit) {
+      return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
+    }
+
+    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Timestamp now) {
+      PersonIdent oldPatchsetCommitter =
+          Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
+      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen())) {
+        /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
+         * In real situations, this automatically happens as two patchsets won't have exactly the
+         * same commit timestamp even when the tree and commit message are the same. In tests,
+         * we can easily end up with the same timestamp as Git uses second precision for timestamps.
+         * We could of course require that tests must use TestTimeUtil#setClockStep but
+         * that would be an unnecessary nuisance for test writers. Hence, go with a simple solution
+         * here and simply add a second. */
+        now = Timestamp.from(now.toInstant().plusSeconds(1));
+      }
+      return new PersonIdent(oldPatchsetCommitter, now);
+    }
+
+    private long asSeconds(Date date) {
+      return date.getTime() / 1000;
+    }
+
+    private ImmutableList<ObjectId> getParents(RevCommit oldPatchsetCommit) {
+      return Arrays.stream(oldPatchsetCommit.getParents())
+          .map(ObjectId::toObjectId)
+          .collect(toImmutableList());
+    }
+
+    private PatchSetInserter getPatchSetInserter(
+        ChangeNotes changeNotes, ObjectId newPatchsetCommit, PatchSet.Id patchsetId) {
+      PatchSetInserter patchSetInserter =
+          patchsetInserterFactory.create(changeNotes, patchsetId, newPatchsetCommit);
+      patchSetInserter.setCheckAddPatchSetPermission(false);
+      patchSetInserter.setMessage(String.format("Uploaded patchset %d.", patchsetId.get()));
+      return patchSetInserter;
+    }
+
+    @Override
+    public PerPatchsetOperations patchset(PatchSet.Id patchsetId) {
+      return perPatchsetOperationsFactory.create(getChangeNotes(), patchsetId);
+    }
+
+    @Override
+    public PerPatchsetOperations currentPatchset() {
+      ChangeNotes changeNotes = getChangeNotes();
+      return perPatchsetOperationsFactory.create(
+          changeNotes, changeNotes.getChange().currentPatchSetId());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
new file mode 100644
index 0000000..bae8407
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.DeleteFileModification;
+import com.google.gerrit.server.edit.tree.RenameFileModification;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.function.Consumer;
+
+/** Builder to simplify file content specification. */
+public class FileContentBuilder<T> {
+  private final T builder;
+  private final String filePath;
+  private final Consumer<TreeModification> modificationToBuilderAdder;
+
+  FileContentBuilder(
+      T builder, String filePath, Consumer<TreeModification> modificationToBuilderAdder) {
+    checkNotNull(Strings.emptyToNull(filePath), "File path must not be null or empty.");
+    this.builder = builder;
+    this.filePath = filePath;
+    this.modificationToBuilderAdder = modificationToBuilderAdder;
+  }
+
+  /** Content of the file. Must not be empty. */
+  public T content(String content) {
+    checkNotNull(
+        Strings.emptyToNull(content),
+        "Empty file content is not supported. Adjust test API if necessary.");
+    modificationToBuilderAdder.accept(
+        new ChangeFileContentModification(filePath, RawInputUtil.create(content)));
+    return builder;
+  }
+
+  public T delete() {
+    modificationToBuilderAdder.accept(new DeleteFileModification(filePath));
+    return builder;
+  }
+
+  public T renameTo(String newFilePath) {
+    modificationToBuilderAdder.accept(new RenameFileModification(filePath, newFilePath));
+    return builder;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
new file mode 100644
index 0000000..c095551
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.acceptance.testsuite.change;
+
+/** An aggregation of methods on a specific patchset. */
+public interface PerPatchsetOperations {
+
+  /**
+   * Retrieves the patchset.
+   *
+   * <p><strong>Note:</strong> This call will fail with an exception if the requested patchset
+   * doesn't exist.
+   *
+   * @return the corresponding {@code TestPatchset}
+   */
+  TestPatchset get();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
new file mode 100644
index 0000000..8c9a495
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/**
+ * The implementation of {@link PerPatchsetOperations}.
+ *
+ * <p>There is only one implementation of {@link PerPatchsetOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class PerPatchsetOperationsImpl implements PerPatchsetOperations {
+  private final ChangeNotes changeNotes;
+  private final PatchSet.Id patchsetId;
+
+  public interface Factory {
+    PerPatchsetOperationsImpl create(ChangeNotes changeNotes, PatchSet.Id patchsetId);
+  }
+
+  @Inject
+  private PerPatchsetOperationsImpl(
+      @Assisted ChangeNotes changeNotes, @Assisted PatchSet.Id patchsetId) {
+    this.changeNotes = changeNotes;
+    this.patchsetId = patchsetId;
+  }
+
+  @Override
+  public TestPatchset get() {
+    PatchSet patchset = changeNotes.getPatchSets().get(patchsetId);
+    return TestPatchset.builder().patchsetId(patchsetId).commitId(patchset.commitId()).build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java
new file mode 100644
index 0000000..ea2acaa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+
+/** Representation of a change used for testing purposes. */
+@AutoValue
+public abstract class TestChange {
+
+  /**
+   * The numeric change ID, sometimes also called change number or legacy change ID. Unique per
+   * host.
+   */
+  public abstract Change.Id numericChangeId();
+
+  /**
+   * The Change-Id as specified in the commit message. Consists of an {@code I} followed by a 40-hex
+   * string. Only unique per project-branch.
+   */
+  public abstract String changeId();
+
+  static Builder builder() {
+    return new AutoValue_TestChange.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder numericChangeId(Change.Id numericChangeId);
+
+    abstract Builder changeId(String changeId);
+
+    abstract TestChange build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
new file mode 100644
index 0000000..65db967
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+
+/** Initial attributes of the change. If not provided, arbitrary values will be used. */
+@AutoValue
+public abstract class TestChangeCreation {
+  public abstract Optional<Project.NameKey> project();
+
+  public abstract String branch();
+
+  public abstract Optional<Account.Id> owner();
+
+  public abstract String commitMessage();
+
+  public abstract ImmutableList<TreeModification> treeModifications();
+
+  abstract ThrowingFunction<TestChangeCreation, Change.Id> changeCreator();
+
+  public static Builder builder(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator) {
+    return new AutoValue_TestChangeCreation.Builder()
+        .changeCreator(changeCreator)
+        .branch(Constants.R_HEADS + Constants.MASTER)
+        .commitMessage("A test change");
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    /** Target project/Repository of the change. Must be an existing project. */
+    public abstract Builder project(Project.NameKey project);
+
+    /**
+     * Target branch of the change. Neither needs to exist nor needs to point to an actual commit.
+     */
+    public abstract Builder branch(String branch);
+
+    /** The change owner. Must be an existing user account. */
+    public abstract Builder owner(Account.Id owner);
+
+    /**
+     * The commit message. The message may contain a {@code Change-Id} footer but does not need to.
+     * If the footer is absent, it will be generated.
+     */
+    public abstract Builder commitMessage(String commitMessage);
+
+    /** Modified file of the change. The file content is specified via the returned builder. */
+    public FileContentBuilder<Builder> file(String filePath) {
+      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+    }
+
+    abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+    abstract Builder changeCreator(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator);
+
+    abstract TestChangeCreation autoBuild();
+
+    /**
+     * Creates the change.
+     *
+     * @return the {@code Change.Id} of the created change
+     */
+    public Change.Id create() {
+      TestChangeCreation changeUpdate = autoBuild();
+      return changeUpdate.changeCreator().applyAndThrowSilently(changeUpdate);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java
new file mode 100644
index 0000000..1ba242a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Representation of a patchset used for testing purposes. */
+@AutoValue
+public abstract class TestPatchset {
+
+  /** The numeric patchset ID. */
+  public abstract PatchSet.Id patchsetId();
+
+  /** The commit SHA-1 of the patchset. */
+  public abstract ObjectId commitId();
+
+  static Builder builder() {
+    return new AutoValue_TestPatchset.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder patchsetId(PatchSet.Id patchsetId);
+
+    abstract Builder commitId(ObjectId commitId);
+
+    abstract TestPatchset build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
new file mode 100644
index 0000000..571b1e1
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.edit.tree.TreeModification;
+
+/** Initial attributes of the patchset. If not provided, arbitrary values will be used. */
+@AutoValue
+public abstract class TestPatchsetCreation {
+
+  public abstract ImmutableList<TreeModification> treeModifications();
+
+  abstract ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator();
+
+  public static TestPatchsetCreation.Builder builder(
+      ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator) {
+    return new AutoValue_TestPatchsetCreation.Builder().patchsetCreator(patchsetCreator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    /** Modified file of the patchset. The file content is specified via the returned builder. */
+    public FileContentBuilder<Builder> file(String filePath) {
+      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+    }
+
+    abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+    abstract TestPatchsetCreation.Builder patchsetCreator(
+        ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator);
+
+    abstract TestPatchsetCreation autoBuild();
+
+    /**
+     * Creates the patchset.
+     *
+     * @return the {@code PatchSet.Id} of the created patchset
+     */
+    public PatchSet.Id create() {
+      TestPatchsetCreation patchsetCreation = autoBuild();
+      return patchsetCreation.patchsetCreator().applyAndThrowSilently(patchsetCreation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index c894059..e4d594b 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.testsuite.project;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -43,12 +44,10 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import org.apache.commons.lang.RandomStringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectLoader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -91,7 +90,8 @@
 
     CreateProjectArgs args = new CreateProjectArgs();
     args.setProjectName(name);
-    args.branch = Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
+    args.branch =
+        projectCreation.branches().stream().map(RefNames::fullName).collect(toImmutableList());
     args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
     projectCreation.parent().ifPresent(p -> args.newParent = p);
     // ProjectCreator wants non-null owner IDs.
@@ -211,9 +211,7 @@
     }
 
     private RevCommit headOrNull(String branch) {
-      if (!branch.startsWith(Constants.R_REFS)) {
-        branch = RefNames.REFS_HEADS + branch;
-      }
+      branch = RefNames.fullName(branch);
 
       try (Repository repo = repoManager.openRepository(nameKey);
           RevWalk rw = new RevWalk(repo)) {
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 99e045c..2649dea 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -15,10 +15,14 @@
 package com.google.gerrit.acceptance.testsuite.project;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.lib.Constants;
 
 @AutoValue
 public abstract class TestProjectCreation {
@@ -27,6 +31,8 @@
 
   public abstract Optional<Project.NameKey> parent();
 
+  public abstract ImmutableSet<String> branches();
+
   public abstract Optional<Boolean> createEmptyCommit();
 
   public abstract Optional<SubmitType> submitType();
@@ -35,7 +41,9 @@
 
   public static Builder builder(
       ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator) {
-    return new AutoValue_TestProjectCreation.Builder().projectCreator(projectCreator);
+    return new AutoValue_TestProjectCreation.Builder()
+        .branches(Constants.R_HEADS + Constants.MASTER)
+        .projectCreator(projectCreator);
   }
 
   @AutoValue.Builder
@@ -46,6 +54,17 @@
 
     public abstract TestProjectCreation.Builder submitType(SubmitType submitType);
 
+    /**
+     * Branches which should be created in the repository (with an empty root commit). The
+     * "refs/heads/" prefix of the branch name can be omitted. The specified branches are ignored if
+     * {@link #noEmptyCommit()} is used.
+     */
+    public TestProjectCreation.Builder branches(String branch1, String... otherBranches) {
+      return branches(Sets.union(ImmutableSet.of(branch1), ImmutableSet.copyOf(otherBranches)));
+    }
+
+    abstract TestProjectCreation.Builder branches(Set<String> branches);
+
     public abstract TestProjectCreation.Builder createEmptyCommit(boolean value);
 
     /** Skips the empty commit on creation. This means that project's branches will not exist. */
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index d6fcb37..d344e18 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -18,11 +18,13 @@
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.gitPersons;
 import static com.google.gerrit.truth.ListSubject.elements;
 
+import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 
 public class CommitInfoSubject extends Subject {
 
@@ -65,4 +67,8 @@
     isNotNull();
     return check("message").that(commitInfo.message);
   }
+
+  public static Correspondence<CommitInfo, String> hasCommit() {
+    return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasCommit");
+  }
 }
diff --git a/java/com/google/gerrit/server/config/PluginConfig.java b/java/com/google/gerrit/server/config/PluginConfig.java
index 7dd981c..2459d0b 100644
--- a/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/java/com/google/gerrit/server/config/PluginConfig.java
@@ -61,6 +61,13 @@
         pluginName, copyConfig(cfg), Optional.ofNullable(projectConfig), groupReferences.build());
   }
 
+  public static PluginConfig createFromGerritConfig(String pluginName, Config cfg) {
+    // There is no need to make a defensive copy here because this value won't be cached.
+    // gerrit.config uses baseConfig's (a member of Config) which would also make defensive copies
+    // fail.
+    return new AutoValue_PluginConfig(pluginName, cfg, Optional.empty(), ImmutableMap.of());
+  }
+
   PluginConfig withInheritance(ProjectState.Factory projectStateFactory) {
     checkState(projectConfig().isPresent(), "no project config provided");
 
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 0028d63..a9abd1e 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -111,7 +111,7 @@
       cfgSnapshot = FileSnapshot.save(configFile);
       cfg = cfgProvider.get();
     }
-    return PluginConfig.create(pluginName, cfg, null);
+    return PluginConfig.createFromGerritConfig(pluginName, cfg);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 48683ea..a05c392 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -498,7 +498,7 @@
       throws BadRequestException, IOException, InvalidChangeOperationException {
     ObjectId newTreeId;
     try {
-      TreeCreator treeCreator = new TreeCreator(baseCommit);
+      TreeCreator treeCreator = TreeCreator.basedOn(baseCommit);
       treeCreator.addTreeModifications(treeModifications);
       newTreeId = treeCreator.createNewTreeAndGetId(repository);
     } catch (InvalidPathException e) {
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 0adacd8..4821a7a 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -18,6 +18,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.extensions.restapi.RawInput;
@@ -33,7 +34,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 /** A {@code TreeModification} which changes the content of a file. */
 public class ChangeFileContentModification implements TreeModification {
@@ -48,7 +48,8 @@
   }
 
   @Override
-  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+  public List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
     DirCacheEditor.PathEdit changeContentEdit = new ChangeContent(filePath, newContent, repository);
     return Collections.singletonList(changeContentEdit);
   }
diff --git a/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java b/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
index feffb70..3b7826c 100644
--- a/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import com.google.common.collect.ImmutableList;
 import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 /** A {@code TreeModification} which deletes a file. */
 public class DeleteFileModification implements TreeModification {
@@ -30,7 +31,8 @@
   }
 
   @Override
-  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+  public List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
     DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(filePath);
     return Collections.singletonList(deletePathEdit);
   }
diff --git a/java/com/google/gerrit/server/edit/tree/RenameFileModification.java b/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
index b847599..d4412e4 100644
--- a/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
-import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
 
@@ -36,19 +35,23 @@
   }
 
   @Override
-  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+  public List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
       throws IOException {
+    if (ObjectId.zeroId().equals(treeId)) {
+      return ImmutableList.of();
+    }
+
     try (RevWalk revWalk = new RevWalk(repository)) {
-      revWalk.parseHeaders(baseCommit);
       try (TreeWalk treeWalk =
-          TreeWalk.forPath(revWalk.getObjectReader(), currentFilePath, baseCommit.getTree())) {
+          TreeWalk.forPath(revWalk.getObjectReader(), currentFilePath, treeId)) {
         if (treeWalk == null) {
-          return Collections.emptyList();
+          return ImmutableList.of();
         }
         DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(currentFilePath);
         AddPath addPathEdit =
             new AddPath(newFilePath, treeWalk.getFileMode(0), treeWalk.getObjectId(0));
-        return Arrays.asList(deletePathEdit, addPathEdit);
+        return ImmutableList.of(deletePathEdit, addPathEdit);
       }
     }
   }
diff --git a/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java b/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
index 393a866..730664b 100644
--- a/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -36,16 +38,16 @@
   }
 
   @Override
-  public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+  public List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
       throws IOException {
-    if (baseCommit.getParentCount() == 0) {
+    if (parents.isEmpty()) {
       DirCacheEditor.DeletePath deletePath = new DirCacheEditor.DeletePath(filePath);
       return Collections.singletonList(deletePath);
     }
 
-    RevCommit base = baseCommit.getParent(0);
     try (RevWalk revWalk = new RevWalk(repository)) {
-      revWalk.parseHeaders(base);
+      RevCommit base = revWalk.parseCommit(parents.get(0));
       try (TreeWalk treeWalk =
           TreeWalk.forPath(revWalk.getObjectReader(), filePath, base.getTree())) {
         if (treeWalk == null) {
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index e6caf97..3c400ec 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -31,15 +32,26 @@
 
 /**
  * A creator for a new Git tree. To create the new tree, the tree of another commit is taken as a
- * basis and modified.
+ * basis and modified. Alternatively, an empty tree can serve as base.
  */
 public class TreeCreator {
 
-  private final RevCommit baseCommit;
+  private final ObjectId baseTreeId;
+  private final ImmutableList<? extends ObjectId> baseParents;
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
-  public TreeCreator(RevCommit baseCommit) {
-    this.baseCommit = requireNonNull(baseCommit, "baseCommit is required");
+  public static TreeCreator basedOn(RevCommit baseCommit) {
+    requireNonNull(baseCommit, "baseCommit is required");
+    return new TreeCreator(baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()));
+  }
+
+  public static TreeCreator basedOnEmptyTree() {
+    return new TreeCreator(ObjectId.zeroId(), ImmutableList.of());
+  }
+
+  private TreeCreator(ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+    this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
+    this.baseParents = baseParents;
   }
 
   /**
@@ -78,8 +90,9 @@
     try (ObjectReader objectReader = repository.newObjectReader()) {
       DirCache dirCache = DirCache.newInCore();
       DirCacheBuilder dirCacheBuilder = dirCache.builder();
-      dirCacheBuilder.addTree(
-          new byte[0], DirCacheEntry.STAGE_0, objectReader, baseCommit.getTree());
+      if (!ObjectId.zeroId().equals(baseTreeId)) {
+        dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, objectReader, baseTreeId);
+      }
       dirCacheBuilder.finish();
       return dirCache;
     }
@@ -88,7 +101,8 @@
   private List<DirCacheEditor.PathEdit> getPathEdits(Repository repository) throws IOException {
     List<DirCacheEditor.PathEdit> pathEdits = new ArrayList<>();
     for (TreeModification treeModification : treeModifications) {
-      pathEdits.addAll(treeModification.getPathEdits(repository, baseCommit));
+      pathEdits.addAll(
+          treeModification.getPathEdits(repository, baseTreeId, ImmutableList.copyOf(baseParents)));
     }
     return pathEdits;
   }
diff --git a/java/com/google/gerrit/server/edit/tree/TreeModification.java b/java/com/google/gerrit/server/edit/tree/TreeModification.java
index 2656707..68fd7ea 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeModification.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeModification.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.server.edit.tree;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 
 /** A specific modification of a Git tree. */
 public interface TreeModification {
@@ -30,11 +31,14 @@
    * shouldn't be changed.
    *
    * @param repository the affected Git repository
-   * @param baseCommit the commit to whose tree this modification is applied
+   * @param treeId tree to which the modification is applied. A value of {@code ObjectId.zero()}
+   *     indicates an empty tree.
+   * @param parents parent commits of the commit to whose tree this modification is applied
    * @return an ordered list of necessary {@code PathEdit}s
    * @throws IOException if problems arise when accessing the repository
    */
-  List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+  List<DirCacheEditor.PathEdit> getPathEdits(
+      Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
       throws IOException;
 
   /**
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 663c9aa..d2e1086 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -56,7 +56,6 @@
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.time.Duration;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
@@ -224,9 +223,7 @@
   @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
-      return all().stream()
-          .map(n -> byName.getIfPresent(n))
-          .filter(Objects::nonNull)
+      return byName.asMap().values().stream()
           .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
           // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
           // against them just in case there is a bug or corner case.
diff --git a/java/com/google/gerrit/truth/MapSubject.java b/java/com/google/gerrit/truth/MapSubject.java
index 95a0e0c..4eba753 100644
--- a/java/com/google/gerrit/truth/MapSubject.java
+++ b/java/com/google/gerrit/truth/MapSubject.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertAbout;
 
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
 import com.google.common.truth.IterableSubject;
 import com.google.common.truth.Subject;
 import java.util.Map;
@@ -51,4 +52,9 @@
     isNotNull();
     return check("values()").that(map.values());
   }
+
+  public IntegerSubject size() {
+    isNotNull();
+    return check("size()").that(map.size());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
new file mode 100644
index 0000000..4783db7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -0,0 +1,462 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.hasCommit;
+import static com.google.gerrit.extensions.restapi.testing.BinaryResultSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ChangeOperationsImplTest extends AbstractDaemonTest {
+
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void changeCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    Change.Id numericChangeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(numericChangeId);
+    assertThat(change._number).isEqualTo(numericChangeId.get());
+    assertThat(change.changeId).isNotEmpty();
+  }
+
+  @Test
+  public void changeCanBeCreatedEvenWithRequestScopeOfArbitraryUser() throws Exception {
+    Account.Id user = accountOperations.newAccount().create();
+
+    requestScopeOperations.setApiUser(user);
+    Change.Id numericChangeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(numericChangeId);
+    assertThat(change._number).isEqualTo(numericChangeId.get());
+  }
+
+  @Test
+  public void twoChangesWithoutAnyParametersDoNotClash() {
+    Change.Id changeId1 = changeOperations.newChange().create();
+    Change.Id changeId2 = changeOperations.newChange().create();
+
+    TestChange change1 = changeOperations.change(changeId1).get();
+    TestChange change2 = changeOperations.change(changeId2).get();
+    assertThat(change1.numericChangeId()).isNotEqualTo(change2.numericChangeId());
+    assertThat(change1.changeId()).isNotEqualTo(change2.changeId());
+  }
+
+  @Test
+  public void twoSubsequentlyCreatedChangesDoNotDependOnEachOther() throws Exception {
+    Change.Id changeId1 = changeOperations.newChange().create();
+    Change.Id changeId2 = changeOperations.newChange().create();
+
+    ChangeInfo change1 = getChangeFromServer(changeId1);
+    ChangeInfo change2 = getChangeFromServer(changeId2);
+    CommitInfo currentPatchsetCommit1 = change1.revisions.get(change1.currentRevision).commit;
+    CommitInfo currentPatchsetCommit2 = change2.revisions.get(change2.currentRevision).commit;
+    assertThat(currentPatchsetCommit1)
+        .parents()
+        .comparingElementsUsing(hasCommit())
+        .doesNotContain(currentPatchsetCommit2.commit);
+    assertThat(currentPatchsetCommit2)
+        .parents()
+        .comparingElementsUsing(hasCommit())
+        .doesNotContain(currentPatchsetCommit1.commit);
+  }
+
+  @Test
+  public void createdChangeHasAtLeastOnePatchset() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThatMap(change.revisions).size().isAtLeast(1);
+  }
+
+  @Test
+  public void createdChangeIsInSpecifiedProject() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.project).isEqualTo(project.get());
+  }
+
+  @Test
+  public void changeCanBeCreatedInEmptyRepository() throws Exception {
+    Project.NameKey project = projectOperations.newProject().noEmptyCommit().create();
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.project).isEqualTo(project.get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedTargetBranch() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).branch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.branch).isEqualTo("test-branch");
+  }
+
+  @Test
+  public void createdChangeUsesTipOfTargetBranchAsParentByDefault() throws Exception {
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).branch("test-branch").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .parents()
+        .onlyElement()
+        .commit()
+        .isEqualTo(parentCommitId.getName());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedOwner() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+  }
+
+  @Test
+  public void changeOwnerDoesNotNeedAnyPermissionsForChangeCreation() throws Exception {
+    Account.Id changeOwner = accountOperations.newAccount().create();
+    Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+    // Remove any read and push permissions which might potentially exist. Without read, users
+    // shouldn't be able to do anything. The newly created project should only inherit from
+    // All-Projects.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/heads/test-branch"))
+        .remove(permissionKey(Permission.PUSH).ref("refs/heads/test-branch"))
+        .update();
+    projectOperations
+        .allProjectsForUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/heads/test-branch"))
+        .remove(permissionKey(Permission.PUSH).ref("refs/heads/test-branch"))
+        .update();
+
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .owner(changeOwner)
+            .branch("test-branch")
+            .project(project)
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedCommitMessage() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nDetailed description.")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit).message().startsWith("Summary line\n\nDetailed description.");
+  }
+
+  @Test
+  public void changeCannotBeCreatedWithoutCommitMessage() {
+    assertThrows(
+        IllegalStateException.class, () -> changeOperations.newChange().commitMessage("").create());
+  }
+
+  @Test
+  public void commitMessageOfCreatedChangeAutomaticallyGetsChangeId() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nDetailed description.")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit).message().contains("Change-Id:");
+  }
+
+  @Test
+  public void changeIdSpecifiedInCommitMessageIsKeptForCreatedChange() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nChange-Id: I0123456789012345678901234567890123456789")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+    assertThat(currentPatchsetCommit)
+        .message()
+        .contains("Change-Id: I0123456789012345678901234567890123456789");
+    assertThat(change.changeId).isEqualTo("I0123456789012345678901234567890123456789");
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedFiles() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1")
+            .file("path/to/file2.txt")
+            .content("Line one")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "path/to/file2.txt");
+    BinaryResult fileContent1 = gApi.changes().id(changeId.get()).current().file("file1").content();
+    assertThat(fileContent1).asString().isEqualTo("Line 1");
+    BinaryResult fileContent2 =
+        gApi.changes().id(changeId.get()).current().file("path/to/file2.txt").content();
+    assertThat(fileContent2).asString().isEqualTo("Line one");
+  }
+
+  @Test
+  public void existingChangeCanBeCheckedForExistence() {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    boolean exists = changeOperations.change(changeId).exists();
+
+    assertThat(exists).isTrue();
+  }
+
+  @Test
+  public void notExistingChangeCanBeCheckedForExistence() {
+    Change.Id changeId = Change.id(123456789);
+
+    boolean exists = changeOperations.change(changeId).exists();
+
+    assertThat(exists).isFalse();
+  }
+
+  @Test
+  public void retrievingNotExistingChangeFails() {
+    Change.Id changeId = Change.id(123456789);
+    assertThrows(IllegalStateException.class, () -> changeOperations.change(changeId).get());
+  }
+
+  @Test
+  public void numericChangeIdOfExistingChangeCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    TestChange change = changeOperations.change(changeId).get();
+    assertThat(change.numericChangeId()).isEqualTo(changeId);
+  }
+
+  @Test
+  public void changeIdOfExistingChangeCanBeRetrieved() {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .commitMessage("Summary line\n\nChange-Id: I0123456789012345678901234567890123456789")
+            .create();
+
+    TestChange change = changeOperations.change(changeId).get();
+    assertThat(change.changeId()).isEqualTo("I0123456789012345678901234567890123456789");
+  }
+
+  @Test
+  public void currentPatchsetOfExistingChangeCanBeRetrieved() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    TestPatchset patchset = changeOperations.change(changeId).currentPatchset().get();
+
+    ChangeInfo expectedChange = getChangeFromServer(changeId);
+    String expectedCommitId = expectedChange.currentRevision;
+    int expectedPatchsetNumber = expectedChange.revisions.get(expectedCommitId)._number;
+    assertThat(patchset.commitId()).isEqualTo(ObjectId.fromString(expectedCommitId));
+    assertThat(patchset.patchsetId()).isEqualTo(PatchSet.id(changeId, expectedPatchsetNumber));
+  }
+
+  @Test
+  public void earlierPatchsetOfExistingChangeCanBeRetrieved() {
+    Change.Id changeId = changeOperations.newChange().create();
+    PatchSet.Id earlierPatchsetId =
+        changeOperations.change(changeId).currentPatchset().get().patchsetId();
+    PatchSet.Id currentPatchsetId = changeOperations.change(changeId).newPatchset().create();
+
+    TestPatchset earlierPatchset =
+        changeOperations.change(changeId).patchset(earlierPatchsetId).get();
+
+    assertThat(earlierPatchset.patchsetId()).isEqualTo(earlierPatchsetId);
+    assertThat(earlierPatchset.patchsetId()).isNotEqualTo(currentPatchsetId);
+  }
+
+  @Test
+  public void newPatchsetCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+    ChangeInfo unmodifiedChange = getChangeFromServer(changeId);
+    int originalPatchsetCount = unmodifiedChange.revisions.size();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).newPatchset().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThatMap(change.revisions).hasSize(originalPatchsetCount + 1);
+    RevisionInfo currentRevision = change.revisions.get(change.currentRevision);
+    assertThat(currentRevision._number).isEqualTo(patchsetId.get());
+  }
+
+  @Test
+  public void newPatchsetIsCopyOfPreviousPatchsetByDefault() throws Exception {
+    Change.Id changeId = changeOperations.newChange().create();
+
+    PatchSet.Id patchsetId = changeOperations.change(changeId).newPatchset().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    RevisionInfo patchsetRevision = getRevision(change, patchsetId);
+    assertThat(patchsetRevision.kind).isEqualTo(ChangeKind.NO_CHANGE);
+  }
+
+  @Test
+  public void newPatchsetCanHaveReplacedFileContent() throws Exception {
+    Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+    PatchSet.Id patchsetId =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file1")
+            .content("Different content")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1");
+    BinaryResult fileContent = getFileContent(changeId, patchsetId, "file1");
+    assertThat(fileContent).asString().isEqualTo("Different content");
+  }
+
+  @Test
+  public void newPatchsetCanHaveAdditionalFile() throws Exception {
+    Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+    PatchSet.Id patchsetId =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file2")
+            .content("My file content")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "file2");
+    BinaryResult fileContent = getFileContent(changeId, patchsetId, "file2");
+    assertThat(fileContent).asString().isEqualTo("My file content");
+  }
+
+  @Test
+  public void newPatchsetCanHaveLessFiles() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1")
+            .file("file2")
+            .content("Line one")
+            .create();
+
+    changeOperations.change(changeId).newPatchset().file("file2").delete().create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1");
+  }
+
+  @Test
+  public void newPatchsetCanHaveRenamedFile() throws Exception {
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file("file1")
+            .content("Line 1")
+            .file("file2")
+            .content("Line one")
+            .create();
+
+    PatchSet.Id patchsetId =
+        changeOperations
+            .change(changeId)
+            .newPatchset()
+            .file("file2")
+            .renameTo("renamed file")
+            .create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+    assertThatMap(files).keys().containsExactly("file1", "renamed file");
+    BinaryResult fileContent = getFileContent(changeId, patchsetId, "renamed file");
+    assertThat(fileContent).asString().isEqualTo("Line one");
+  }
+
+  private ChangeInfo getChangeFromServer(Change.Id changeId) throws RestApiException {
+    return gApi.changes().id(changeId.get()).get();
+  }
+
+  private RevisionInfo getRevision(ChangeInfo change, PatchSet.Id patchsetId) {
+    return change.revisions.values().stream()
+        .filter(revision -> revision._number == patchsetId.get())
+        .findAny()
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format(
+                        "Change %d doesn't have specified patchset %d.",
+                        change._number, patchsetId.get())));
+  }
+
+  private BinaryResult getFileContent(Change.Id changeId, PatchSet.Id patchsetId, String filePath)
+      throws RestApiException {
+    return gApi.changes().id(changeId.get()).revision(patchsetId.get()).file(filePath).content();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 76b8826..62dfc63 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -36,6 +36,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
 import com.google.gerrit.entities.Permission;
@@ -43,6 +44,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
@@ -82,6 +84,36 @@
   }
 
   @Test
+  public void specifiedBranchesAreCreatedInNewProject() throws Exception {
+    Project.NameKey project =
+        projectOperations
+            .newProject()
+            .branches("test-branch", "refs/heads/another-test-branch")
+            .create();
+
+    List<BranchInfo> branches = gApi.projects().name(project.get()).branches().get();
+    assertThat(branches)
+        .comparingElementsUsing(hasBranchName())
+        .containsAtLeast("refs/heads/test-branch", "refs/heads/another-test-branch");
+  }
+
+  @Test
+  public void specifiedBranchesAreNotCreatedInNewProjectIfNoEmptyCommitRequested()
+      throws Exception {
+    Project.NameKey project =
+        projectOperations
+            .newProject()
+            .branches("test-branch", "refs/heads/another-test-branch")
+            .noEmptyCommit()
+            .create();
+
+    List<BranchInfo> branches = gApi.projects().name(project.get()).branches().get();
+    assertThat(branches)
+        .comparingElementsUsing(hasBranchName())
+        .containsNoneOf("refs/heads/test-branch", "refs/heads/another-test-branch");
+  }
+
+  @Test
   public void getProjectConfig() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
@@ -571,4 +603,8 @@
       tr.delete(REFS_CONFIG);
     }
   }
+
+  private static Correspondence<BranchInfo, String> hasBranchName() {
+    return NullAwareCorrespondence.transforming(branch -> branch.ref, "hasName");
+  }
 }
diff --git a/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java b/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java
new file mode 100644
index 0000000..926c345
--- /dev/null
+++ b/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.edit.tree;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.RawInputUtil;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TreeCreatorTest {
+
+  private Repository repository;
+  private TestRepository<?> testRepository;
+
+  @Before
+  public void setUp() throws Exception {
+    repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
+    testRepository = new TestRepository<>(repository);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (testRepository != null) {
+      testRepository.close();
+    }
+  }
+
+  @Test
+  public void fileContentModificationWorksWithEmptyTree() throws Exception {
+    TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+    treeCreator.addTreeModifications(
+        ImmutableList.of(
+            new ChangeFileContentModification("file.txt", RawInputUtil.create("Line 1"))));
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    String fileContent = getFileContent(newTreeId, "file.txt");
+    assertThat(fileContent).isEqualTo("Line 1");
+  }
+
+  @Test
+  public void renameFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+    TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+    treeCreator.addTreeModifications(
+        ImmutableList.of(new RenameFileModification("oldfileName", "newFileName")));
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    assertThat(isEmptyTree(newTreeId)).isTrue();
+  }
+
+  @Test
+  public void deleteFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+    TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+    treeCreator.addTreeModifications(ImmutableList.of(new DeleteFileModification("file.txt")));
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    assertThat(isEmptyTree(newTreeId)).isTrue();
+  }
+
+  @Test
+  public void restoreFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+    TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+    treeCreator.addTreeModifications(ImmutableList.of(new RestoreFileModification("file.txt")));
+    ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+    assertThat(isEmptyTree(newTreeId)).isTrue();
+  }
+
+  private String getFileContent(ObjectId treeId, String filePath) throws Exception {
+    try (RevWalk revWalk = new RevWalk(repository);
+        ObjectReader reader = revWalk.getObjectReader()) {
+      RevTree revTree = revWalk.parseTree(treeId);
+      RevObject revObject = testRepository.get(revTree, filePath);
+      return new String(reader.open(revObject, OBJ_BLOB).getBytes(), UTF_8);
+    }
+  }
+
+  private boolean isEmptyTree(ObjectId treeId) throws Exception {
+    try (TreeWalk treeWalk = new TreeWalk(repository)) {
+      treeWalk.reset(treeId);
+      return !treeWalk.next();
+    }
+  }
+}
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index bf1fcc6..6d19ac9 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -268,6 +268,8 @@
       "rules": {
         // The following rules is required to match internal google rules
         "@typescript-eslint/restrict-plus-operands": "error",
+        // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
+        "node/no-unsupported-features/node-builtins": "off",
       },
       "parserOptions": {
         "project": path.resolve(__dirname, "./tsconfig_eslint.json"),
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
deleted file mode 100644
index 1e37603..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-class CustomPluginHeader extends PolymerElement {
-  static get is() {
-    return 'gr-custom-plugin-header';
-  }
-
-  static get properties() {
-    return {
-      logoUrl: String,
-      title: String,
-    };
-  }
-
-  static get template() {
-    return html`
-    <style>
-      img {
-        width: 1em;
-        height: 1em;
-        vertical-align: middle;
-      }
-      .title {
-        margin-left: var(--spacing-xs);
-      }
-    </style>
-    <span>
-      <img src="[[logoUrl]]" hidden\$="[[!logoUrl]]">
-      <span class="title">[[title]]</span>
-    </span>
-`;
-  }
-}
-
-customElements.define(CustomPluginHeader.is, CustomPluginHeader);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
new file mode 100644
index 0000000..7d82ff4
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-custom-plugin-header': GrCustomPluginHeader;
+  }
+}
+
+@customElement('gr-custom-plugin-header')
+export class GrCustomPluginHeader extends PolymerElement {
+  @property({type: String})
+  logoUrl = '';
+
+  @property({type: String})
+  title = '';
+
+  static get template() {
+    return html`
+      <style>
+        img {
+          width: 1em;
+          height: 1em;
+          vertical-align: middle;
+        }
+        .title {
+          margin-left: var(--spacing-xs);
+        }
+      </style>
+      <span>
+        <img src="[[logoUrl]]" hidden$="[[!logoUrl]]" />
+        <span class="title">[[title]]</span>
+      </span>
+    `;
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
deleted file mode 100644
index 48b14d3..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import './gr-custom-plugin-header.js';
-
-/** @constructor */
-export function GrThemeApi(plugin) {
-  this.plugin = plugin;
-}
-
-GrThemeApi.prototype.setHeaderLogoAndTitle = function(logoUrl, title) {
-  this.plugin.hook('header-title', {replace: true}).onAttached(
-      element => {
-        const customHeader =
-              document.createElement('gr-custom-plugin-header');
-        customHeader.logoUrl = logoUrl;
-        customHeader.title = title;
-        element.appendChild(customHeader);
-      });
-};
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
new file mode 100644
index 0000000..613bde2
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import './gr-custom-plugin-header';
+import {GrCustomPluginHeader} from './gr-custom-plugin-header';
+
+// TODO(TS): replace with Plugin once gr-public-js-api migrated
+interface PluginApi {
+  hook(
+    endpointName: string,
+    option: {replace?: boolean}
+  ): {
+    onAttached(callback: (el: Element) => void): void;
+  };
+}
+
+/**
+ * Defines api for theme, can be used to set header logo and title.
+ */
+export class GrThemeApi {
+  constructor(private readonly plugin: PluginApi) {}
+
+  setHeaderLogoAndTitle(logoUrl: string, title: string) {
+    this.plugin.hook('header-title', {replace: true}).onAttached(element => {
+      const customHeader: GrCustomPluginHeader = document.createElement(
+        'gr-custom-plugin-header'
+      );
+      customHeader.logoUrl = logoUrl;
+      customHeader.title = title;
+      element.appendChild(customHeader);
+    });
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index a0d8d13e..4d75138 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -73,7 +73,7 @@
   }
 
   @property({
-    computed: 'computeAriaDisable(disabled, loading)',
+    computed: 'computeAriaDisabled(disabled, loading)',
     reflectToAttribute: true,
     type: Boolean,
   })
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
deleted file mode 100644
index 9bef3a2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {getBaseUrl} from '../../../utils/url-util.js';
-
-export const PRELOADED_PROTOCOL = 'preloaded:';
-export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
-
-let _restAPI;
-export function getRestAPI() {
-  if (!_restAPI) {
-    _restAPI = document.createElement('gr-rest-api-interface');
-  }
-  return _restAPI;
-}
-
-/**
- * Retrieves the name of the plugin base on the url.
- *
- * @param {string|URL} url
- */
-export function getPluginNameFromUrl(url) {
-  if (!(url instanceof URL)) {
-    try {
-      url = new URL(url);
-    } catch (e) {
-      console.warn(e);
-      return null;
-    }
-  }
-  if (url.protocol === PRELOADED_PROTOCOL) {
-    return url.pathname;
-  }
-  const base = getBaseUrl();
-  let pathname = url.pathname.replace(base, '');
-  // Load from ASSETS_PATH
-  if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
-    pathname = url.href.replace(window.ASSETS_PATH, '');
-  }
-  // Site theme is server from predefined path.
-  if ([
-    '/static/gerrit-theme.html',
-    '/static/gerrit-theme.js',
-  ].includes(pathname)) {
-    return 'gerrit-theme';
-  } else if (!pathname.startsWith('/plugins')) {
-    console.warn('Plugin not being loaded from /plugins base path:',
-        url.href, '— Unable to determine name.');
-    return null;
-  }
-
-  // Pathname should normally look like this:
-  // /plugins/PLUGINNAME/static/SCRIPTNAME.html
-  // Or, for app/samples:
-  // /plugins/PLUGINNAME.html
-  // TODO(taoalpha): guard with a regex
-  return pathname.split('/')[2].split('.')[0];
-}
-
-// TODO(taoalpha): to be deprecated.
-export function send(method, url, opt_callback, opt_payload) {
-  return getRestAPI().send(method, url, opt_payload)
-      .then(response => {
-        if (response.status < 200 || response.status >= 300) {
-          return response.text().then(text => {
-            if (text) {
-              return Promise.reject(new Error(text));
-            } else {
-              return Promise.reject(new Error(response.status));
-            }
-          });
-        } else {
-          return getRestAPI().getResponseObject(response);
-        }
-      })
-      .then(response => {
-        if (opt_callback) {
-          opt_callback(response);
-        }
-        return response;
-      });
-}
-
-// TEST only methods / properties
-
-export function testOnly_resetInternalState() {
-  _restAPI = undefined;
-}
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
new file mode 100644
index 0000000..2b60e94
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {getBaseUrl} from '../../../utils/url-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export const PRELOADED_PROTOCOL = 'preloaded:';
+export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
+
+let _restAPI: RestApiService | undefined;
+export function getRestAPI() {
+  if (!_restAPI) {
+    _restAPI = (document.createElement(
+      'gr-rest-api-interface'
+    ) as unknown) as RestApiService;
+  }
+  return _restAPI;
+}
+
+/**
+ * Retrieves the name of the plugin base on the url.
+ *
+ * @param {string|URL} url
+ */
+export function getPluginNameFromUrl(url: URL | string) {
+  if (!(url instanceof URL)) {
+    try {
+      url = new URL(url);
+    } catch (e) {
+      console.warn(e);
+      return null;
+    }
+  }
+  if (url.protocol === PRELOADED_PROTOCOL) {
+    return url.pathname;
+  }
+  const base = getBaseUrl();
+  let pathname = url.pathname.replace(base, '');
+  // Load from ASSETS_PATH
+  if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
+    pathname = url.href.replace(window.ASSETS_PATH, '');
+  }
+  // Site theme is server from predefined path.
+  if (
+    ['/static/gerrit-theme.html', '/static/gerrit-theme.js'].includes(pathname)
+  ) {
+    return 'gerrit-theme';
+  } else if (!pathname.startsWith('/plugins')) {
+    console.warn(
+      'Plugin not being loaded from /plugins base path:',
+      url.href,
+      '— Unable to determine name.'
+    );
+    return null;
+  }
+
+  // Pathname should normally look like this:
+  // /plugins/PLUGINNAME/static/SCRIPTNAME.html
+  // Or, for app/samples:
+  // /plugins/PLUGINNAME.html
+  // TODO(taoalpha): guard with a regex
+  return pathname.split('/')[2].split('.')[0];
+}
+
+// TODO(taoalpha): to be deprecated.
+export function send(
+  method: string,
+  url: string,
+  opt_callback?: (response: unknown) => void,
+  opt_payload?: unknown
+) {
+  return getRestAPI()
+    .send(method, url, opt_payload)
+    .then(response => {
+      if (response.status < 200 || response.status >= 300) {
+        return response.text().then((text: string | undefined) => {
+          if (text) {
+            return Promise.reject(new Error(text));
+          } else {
+            return Promise.reject(new Error(`${response.status}`));
+          }
+        });
+      } else {
+        return getRestAPI().getResponseObject(response);
+      }
+    })
+    .then(response => {
+      if (opt_callback) {
+        opt_callback(response);
+      }
+      return response;
+    });
+}
+
+// TEST only methods / properties
+
+export function testOnly_resetInternalState() {
+  _restAPI = undefined;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
deleted file mode 100644
index 083b802..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ /dev/null
@@ -1,130 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {IronOverlayBehaviorImpl} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-overlay_html.js';
-import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin.js';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
-
-/**
- * @extends PolymerElement
- */
-class GrOverlay extends IronOverlayMixin(GestureEventListeners(
-    LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-overlay'; }
-  /**
-   * Fired when a fullscreen overlay is closed
-   *
-   * @event fullscreen-overlay-closed
-   */
-
-  /**
-   * Fired when an overlay is opened in full screen mode
-   *
-   * @event fullscreen-overlay-opened
-   */
-
-  static get properties() {
-    return {
-      _fullScreenOpen: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    this._boundHandleClose = () => this.close();
-    super.created();
-    this.addEventListener('iron-overlay-closed',
-        () => this._overlayClosed());
-    this.addEventListener('iron-overlay-cancelled',
-        () => this._overlayClosed());
-  }
-
-  open(...args) {
-    window.addEventListener('popstate', this._boundHandleClose);
-    return new Promise((resolve, reject) => {
-      IronOverlayBehaviorImpl.open.apply(this, args);
-      if (this._isMobile()) {
-        this.dispatchEvent(new CustomEvent('fullscreen-overlay-opened', {
-          composed: true, bubbles: true,
-        }));
-        this._fullScreenOpen = true;
-      }
-      this._awaitOpen(resolve, reject);
-    });
-  }
-
-  _isMobile() {
-    return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
-  }
-
-  // called after iron-overlay is closed. Does not actually close the overlay
-  _overlayClosed() {
-    window.removeEventListener('popstate', this._boundHandleClose);
-    if (this._fullScreenOpen) {
-      this.dispatchEvent(new CustomEvent('fullscreen-overlay-closed', {
-        composed: true, bubbles: true,
-      }));
-      this._fullScreenOpen = false;
-    }
-  }
-
-  /**
-   * Override the focus stops that iron-overlay-behavior tries to find.
-   */
-  setFocusStops(stops) {
-    this.__firstFocusableNode = stops.start;
-    this.__lastFocusableNode = stops.end;
-  }
-
-  /**
-   * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
-   * opening. Eventually replace with a direct way to listen to the overlay.
-   */
-  _awaitOpen(fn, reject) {
-    let iters = 0;
-    const step = () => {
-      this.async(() => {
-        if (this.style.display !== 'none') {
-          fn.call(this);
-        } else if (iters++ < AWAIT_MAX_ITERS) {
-          step.call(this);
-        } else {
-          reject(new Error('gr-overlay _awaitOpen failed to resolve'));
-        }
-      }, AWAIT_STEP);
-    };
-    step.call(this);
-  }
-
-  _id() {
-    return this.getAttribute('id') || 'global';
-  }
-}
-
-customElements.define(GrOverlay.is, GrOverlay);
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
new file mode 100644
index 0000000..aabbac1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-overlay_html';
+import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
+import {customElement, property} from '@polymer/decorators';
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-overlay': GrOverlay;
+  }
+}
+
+@customElement('gr-overlay')
+export class GrOverlay extends IronOverlayMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when a fullscreen overlay is closed
+   *
+   * @event fullscreen-overlay-closed
+   */
+
+  /**
+   * Fired when an overlay is opened in full screen mode
+   *
+   * @event fullscreen-overlay-opened
+   */
+
+  @property({type: Boolean})
+  private _fullScreenOpen = false;
+
+  private _boundHandleClose: () => void = () => super.close();
+
+  private focusableNodes: Node[] | undefined;
+
+  get _focusableNodes() {
+    if (this.focusableNodes) {
+      return this.focusableNodes;
+    }
+    return super._focusableNodes;
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('iron-overlay-closed', () => this._overlayClosed());
+    this.addEventListener('iron-overlay-cancelled', () =>
+      this._overlayClosed()
+    );
+  }
+
+  open() {
+    window.addEventListener('popstate', this._boundHandleClose);
+    return new Promise((resolve, reject) => {
+      super.open.apply(this);
+      if (this._isMobile()) {
+        this.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-opened', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        this._fullScreenOpen = true;
+      }
+      this._awaitOpen(resolve, reject);
+    });
+  }
+
+  _isMobile() {
+    return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
+  }
+
+  // called after iron-overlay is closed. Does not actually close the overlay
+  _overlayClosed() {
+    window.removeEventListener('popstate', this._boundHandleClose);
+    if (this._fullScreenOpen) {
+      this.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-closed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      this._fullScreenOpen = false;
+    }
+  }
+
+  /**
+   * Override the focus stops that iron-overlay-behavior tries to find.
+   */
+  setFocusStops(stops: GrOverlayStops) {
+    this.focusableNodes = [stops.start, stops.end];
+  }
+
+  /**
+   * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+   * opening. Eventually replace with a direct way to listen to the overlay.
+   */
+  _awaitOpen(fn: (this: GrOverlay) => void, reject: (error: Error) => void) {
+    let iters = 0;
+    const step = () => {
+      this.async(() => {
+        if (this.style.display !== 'none') {
+          fn.call(this);
+        } else if (iters++ < AWAIT_MAX_ITERS) {
+          step.call(this);
+        } else {
+          reject(new Error('gr-overlay _awaitOpen failed to resolve'));
+        }
+      }, AWAIT_STEP);
+    };
+    step.call(this);
+  }
+
+  _id() {
+    return this.getAttribute('id') || 'global';
+  }
+}
+
+export interface GrOverlayStops {
+  start: Node;
+  end: Node;
+}
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
index 516877e..5ef17e2 100644
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -101,7 +101,7 @@
       @observe('hasTooltip')
       _setupTooltipListeners() {
         if (!this.mouseenterHandler) {
-          this.mouseenterHandler = () => this._handleShowTooltip();
+          this.mouseenterHandler = this.showHandler;
         }
 
         if (!this.hasTooltip) {
@@ -151,7 +151,7 @@
 
         this._tooltip = tooltip;
         window.addEventListener('scroll', this.windowScrollHandler);
-        this.addEventListener('mouseleave', this.showHandler);
+        this.addEventListener('mouseleave', this.hideHandler);
         this.addEventListener('click', this.hideHandler);
       }
 
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 81db291..c15cee6 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -27,6 +27,7 @@
       text: string,
       options: {callback: (text: string, href?: string) => void}
     ): void;
+    ASSETS_PATH?: string;
   }
 
   interface Performance {