Merge "ChangeQueryBuilder#label: Reject using 'user' and 'group' args together"
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index dab63f6..66c6531 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2397,7 +2397,6 @@
 |Field Name|Description
 |`url`     |The URL to the avatar image.
 |`height`  |The height of the avatar image in pixels.
-|`width`   |The width of the avatar image in pixels.
 |======================
 
 [[capability-info]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 7b6e5c5..6b36749 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -3270,7 +3270,8 @@
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
 ----
 
-To upload a file as binary data in the request body:
+To upload a file as binary data a link:#file-content-input[FileContentInput]
+entity can be specified in the request body:
 
 .Request
 ----
@@ -3283,17 +3284,10 @@
   }
 ----
 
-Note that it must be base-64 encoded data uri.
+When a change edit doesn't exist for this change yet it is created
+automatically.
 
-The "file_mode" field is optional, and if provided must be in octal format. The field
-indicates whether the file is executable or not and has a value of either 100755
-(executable) or 100644 (not executable). If it's unset, this indicates no change
-has been made. New files default to not being executable if this parameter is not
-provided
-
-When change edit doesn't exist for this change yet it is created. When file
-content isn't provided, it is wiped out for that file. As response
-"`204 No Content`" is returned.
+As response "`204 No Content`" is returned:
 
 .Response
 ----
@@ -8260,6 +8254,24 @@
 Only set if link:#download-commands[download commands] are requested.
 |==========================
 
+[[file-content-input]]
+=== FileContentInput
+The `FileContentInput` entity contains information for creating/updating a file
+in a change edit.
+
+[options="header",cols="1,^1,5"]
+|==============================
+|Field Name       ||Description
+|`binary_content` |optional|The file content as a base-64 encoded data URI. If
+no content is provided, an empty is created or if an existing file is updated
+the file content is removed so that the file becomes empty. The content must be
+a SHA1 if the file mode is `160000` (gitlink).
+|`file_mode`      |optional|The file mode in octal format. Supported values are
+`100644` (regular file), `100755` (executable file), `120000` (symlink) and
+`160000` (gitlink).  If unset, new files are created with file mode `100644`
+(regular file) and for existing files the existing file mode is kept.
+|==============================
+
 [[file-info]]
 === FileInfo
 The `FileInfo` entity contains information about a file in a patch set.
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index c43ebf2..5e9136b 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -507,6 +507,47 @@
 UUID was specified and the UUID is already in use, the response is
 "`409 Conflict`".
 
+[[delete-group]]
+=== Delete Group
+--
+'DELETE /groups/link:#group-id[\{group-id\}]'
+--
+
+Delete group.
+The group to delete must be internal group.
+
+This endpoint is only allowed for Gerrit's internal groups; attempting to call on a
+non-internal group will return 405 Method Not Allowed.
+
+.Request
+----
+  DELETE /groups/MyProject-Committers HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+[[delete-internal-group]]
+=== Delete Internal Group
+--
+'POST /groups/link:#group-id[\{group-id\}].delete'
+--
+
+Delete group.
+The deleted group must be internal group.
+
+.Request
+----
+  POST /groups/MyProject-Committers.delete HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[get-group-detail]]
 === Get Group Detail
 --
@@ -1479,47 +1520,6 @@
   HTTP/1.1 204 No Content
 ----
 
-[[delete-group]]
-=== Delete Group
---
-'DELETE /groups/link:#group-id[\{group-id\}]'
---
-
-Delete group.
-The group to delete must be internal group.
-
-This endpoint is only allowed for Gerrit's internal groups; attempting to call on a
-non-internal group will return 405 Method Not Allowed.
-
-.Request
-----
-  DELETE /groups/MyProject-Committers HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 204 No Content
-----
-
-[[delete-internal-group]]
-=== Delete Internal Group
---
-'POST /groups/link:#group-id[\{group-id\}].delete'
---
-
-Delete group.
-The deleted group must be internal group.
-
-.Request
-----
-  POST /groups/MyProject-Committers.delete HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 204 No Content
-----
-
 
 [[ids]]
 == IDs
diff --git a/SUBMITTING_PATCHES b/SUBMITTING_PATCHES
index 14437da..8a5b785 100644
--- a/SUBMITTING_PATCHES
+++ b/SUBMITTING_PATCHES
@@ -49,6 +49,16 @@
 your patch.  It is virtually impossible to remove a patch once it
 has been applied and pushed out.
 
+In order to contribute to Gerrit a Contributor License Agreement (CLA)
+must be completed before contributions are accepted, see
+https://gerrit-review.googlesource.com/Documentation/dev-cla.html.
+
+All submissions to Google Open Source projects need to follow Google’s
+Contributor License Agreement (CLA), in which contributors agree that
+their contribution is an original work of authorship. This doesn’t
+prohibit the use of coding assistance tools, but what’s submitted does
+need to be a contributor’s original creation.
+
 
 (3) Sending your patches.
 
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 975567f..6f6b7c2 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -409,6 +409,7 @@
             RefNames.REFS_GROUPS + "*",
             RefNames.REFS_STARRED_CHANGES + "*",
             RefNames.REFS_DRAFT_COMMENTS + "*")
+        .deleteNewProjects()
         .build();
   }
 
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index c71641b..dcbcad4 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RefPatternMatcher;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Arrays;
@@ -49,6 +50,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -134,28 +136,35 @@
           groupIncludeCache,
           groupIndexer,
           projectCache,
-          input.refsByProject);
+          input.refsByProject,
+          input.deleteNewProjects);
     }
   }
 
   public static class Config {
     private final ImmutableMultimap<Project.NameKey, String> refsByProject;
+    private final boolean deleteNewProjects;
 
-    private Config(ImmutableMultimap<Project.NameKey, String> refsByProject) {
+    private Config(
+        ImmutableMultimap<Project.NameKey, String> refsByProject, boolean deleteNewProjects) {
       this.refsByProject = refsByProject;
+      this.deleteNewProjects = deleteNewProjects;
     }
 
     public Builder toBuilder() {
       Builder builder = new Builder();
       builder.refsByProject.putAll(refsByProject);
+      builder.deleteNewProjects.set(deleteNewProjects);
       return builder;
     }
 
     public static class Builder {
       private final ListMultimap<Project.NameKey, String> refsByProject;
+      private final AtomicBoolean deleteNewProjects;
 
       public Builder() {
         this.refsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
+        this.deleteNewProjects = new AtomicBoolean(false);
       }
 
       public Builder reset(Project.NameKey project, String... refPatterns) {
@@ -167,8 +176,13 @@
         return this;
       }
 
+      public Builder deleteNewProjects() {
+        deleteNewProjects.set(true);
+        return this;
+      }
+
       public Config build() {
-        return new Config(ImmutableMultimap.copyOf(refsByProject));
+        return new Config(ImmutableMultimap.copyOf(refsByProject), deleteNewProjects.get());
       }
     }
   }
@@ -184,9 +198,11 @@
   @Inject @Nullable private ProjectCache projectCache;
 
   private final Multimap<Project.NameKey, String> refsPatternByProject;
+  private final boolean deleteNewProjects;
 
   // State to which to reset to.
   private final ListMultimap<Project.NameKey, RefState> savedRefStatesByProject;
+  private final Set<Project.NameKey> savedProjectSet;
 
   // Results of the resetting
   private ListMultimap<Project.NameKey, String> keptRefsByProject;
@@ -203,7 +219,8 @@
       @Nullable GroupIncludeCache groupIncludeCache,
       @Nullable GroupIndexer groupIndexer,
       @Nullable ProjectCache projectCache,
-      Multimap<Project.NameKey, String> refPatternByProject)
+      Multimap<Project.NameKey, String> refPatternByProject,
+      boolean deleteNewProjects)
       throws IOException {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
@@ -215,7 +232,10 @@
     this.groupIncludeCache = groupIncludeCache;
     this.projectCache = projectCache;
     this.refsPatternByProject = refPatternByProject;
+    this.deleteNewProjects = deleteNewProjects;
     this.savedRefStatesByProject = readRefStates();
+    this.savedProjectSet =
+        repoManager instanceof InMemoryRepositoryManager ? repoManager.list() : projectCache.all();
   }
 
   @Override
@@ -227,6 +247,7 @@
         () -> {
           restoreRefs();
           deleteNewlyCreatedRefs();
+          deleteNewlyCreatedProjects();
           evictCachesAndReindex();
         });
   }
@@ -312,6 +333,18 @@
     }
   }
 
+  private void deleteNewlyCreatedProjects() {
+    if (deleteNewProjects && repoManager instanceof InMemoryRepositoryManager) {
+      InMemoryRepositoryManager inMemoryRepoManager = (InMemoryRepositoryManager) repoManager;
+      Sets.difference(inMemoryRepoManager.list(), savedProjectSet)
+          .forEach(
+              project -> {
+                inMemoryRepoManager.deleteRepository(project);
+                projectCache.evictAndReindex(project);
+              });
+    }
+  }
+
   private void evictCachesAndReindex() throws IOException {
     evictAndReindexProjects();
     evictAndReindexAccounts();
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
index 3d74477..1b80d9e 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
@@ -62,7 +62,9 @@
    *     .createV2();
    * </pre>
    *
-   * <p><strong>Note:</strong> There must be at least one existing user and repository.
+   * <p><strong>Note:</strong> There must be at least one existing user.
+   *
+   * <p><strong>Note:</strong> If a project is not specified the change is created in a new project.
    *
    * @return a builder to create the new change
    */
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 87e76d5..dc3d947 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -42,7 +43,6 @@
 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.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
@@ -52,7 +52,6 @@
 import java.io.IOException;
 import java.time.Instant;
 import java.util.Arrays;
-import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -84,11 +83,11 @@
   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;
   private final PerCommentOperationsImpl.Factory perCommentOperationsFactory;
   private final PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory;
+  private final ProjectOperations projectOperations;
 
   @Inject
   public ChangeOperationsImpl(
@@ -100,11 +99,11 @@
       IdentifiedUser.GenericFactory userFactory,
       @GerritPersonIdent PersonIdent serverIdent,
       BatchUpdate.Factory batchUpdateFactory,
-      ProjectCache projectCache,
       ChangeFinder changeFinder,
       PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory,
       PerCommentOperationsImpl.Factory perCommentOperationsFactory,
-      PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory) {
+      PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory,
+      ProjectOperations projectOperations) {
     this.seq = seq;
     this.changeInserterFactory = changeInserterFactory;
     this.patchsetInserterFactory = patchsetInserterFactory;
@@ -113,11 +112,11 @@
     this.userFactory = userFactory;
     this.serverIdent = serverIdent;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.projectCache = projectCache;
     this.changeFinder = changeFinder;
     this.perPatchsetOperationsFactory = perPatchsetOperationsFactory;
     this.perCommentOperationsFactory = perCommentOperationsFactory;
     this.perDraftCommentOperationsFactory = perDraftCommentOperationsFactory;
+    this.projectOperations = projectOperations;
   }
 
   @Override
@@ -173,22 +172,7 @@
       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();
+    return projectOperations.newProject().create();
   }
 
   private IdentifiedUser getChangeOwner(TestChangeCreation changeCreation)
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index bef6580..1dc7149 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -21,6 +21,7 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Patch.FileMode;
 import java.util.List;
 
 /**
@@ -204,6 +205,10 @@
     public int getMode() {
       return mode;
     }
+
+    public int getModeAsOctal() {
+      return Integer.parseInt(Integer.toOctalString(getMode()));
+    }
   }
 
   private Patch() {}
diff --git a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
index 6349595..81131c8 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
@@ -20,6 +20,25 @@
 /** Content to be added to a file (new or existing) via change edit. */
 public class FileContentInput {
   @DefaultInput public RawInput content;
-  public String binary_content;
+
+  /**
+   * The file content as a base-64 encoded data URI.
+   *
+   * <p>If no content is provided, an empty is created or if an existing file is updated the file
+   * content is removed so that the file becomes empty.
+   *
+   * <p>The content must be a SHA1 if the file mode is {@code 160000} (gitlink).
+   */
+  public String binaryContent;
+
+  /**
+   * The file mode in octal format.
+   *
+   * <p>Supported values are {@code 100644} (regular file), {@code 100755} (executable file), {@code
+   * 120000} (symlink) and {@code 160000} (gitlink).
+   *
+   * <p>If unset, new files are created with file mode {@code 100644} (regular file) and for
+   * existing files the existing file mode is kept.
+   */
   public Integer fileMode;
 }
diff --git a/java/com/google/gerrit/extensions/common/AvatarInfo.java b/java/com/google/gerrit/extensions/common/AvatarInfo.java
index b620ac2..55ae08f 100644
--- a/java/com/google/gerrit/extensions/common/AvatarInfo.java
+++ b/java/com/google/gerrit/extensions/common/AvatarInfo.java
@@ -38,22 +38,17 @@
   /** The height of the avatar image in pixels. */
   public Integer height;
 
-  /** The width of the avatar image in pixels. */
-  public Integer width;
-
   @Override
   public boolean equals(Object o) {
     if (o instanceof AvatarInfo) {
       AvatarInfo avatarInfo = (AvatarInfo) o;
-      return Objects.equals(url, avatarInfo.url)
-          && Objects.equals(height, avatarInfo.height)
-          && Objects.equals(width, avatarInfo.width);
+      return Objects.equals(url, avatarInfo.url) && Objects.equals(height, avatarInfo.height);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(url, height, width);
+    return Objects.hash(url, height);
   }
 }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index be55ec1..3ac5989 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -44,6 +44,8 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
 import com.google.gerrit.server.edit.tree.DeleteFileModification;
+import com.google.gerrit.server.edit.tree.InvalidFileModeException;
+import com.google.gerrit.server.edit.tree.InvalidGitLinkException;
 import com.google.gerrit.server.edit.tree.RenameFileModification;
 import com.google.gerrit.server.edit.tree.RestoreFileModification;
 import com.google.gerrit.server.edit.tree.TreeCreator;
@@ -598,6 +600,14 @@
       newTreeId = treeCreator.createNewTreeAndGetId(repository);
     } catch (InvalidPathException e) {
       throw new BadRequestException(e.getMessage());
+    } catch (InvalidFileModeException e) {
+      throw new BadRequestException(
+          String.format(
+              "file mode %s is invalid: supported values are 100644 (regular file), 100755"
+                  + " (executable file), 120000 (symlink) and 160000 (gitlink)",
+              Integer.toOctalString(e.getInvalidFileMode())));
+    } catch (InvalidGitLinkException e) {
+      throw new BadRequestException(e.getMessage());
     }
 
     if (ObjectId.isEqual(newTreeId, baseCommit.getTree())) {
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index af0496a..6fe9206 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.edit.tree;
 
 import static com.google.gerrit.entities.Patch.FileMode.EXECUTABLE_FILE;
+import static com.google.gerrit.entities.Patch.FileMode.GITLINK;
 import static com.google.gerrit.entities.Patch.FileMode.REGULAR_FILE;
+import static com.google.gerrit.entities.Patch.FileMode.SYMLINK;
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -31,6 +33,7 @@
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
 import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -74,7 +77,7 @@
 
   /** A {@code PathEdit} which changes the contents of a file. */
   private static class ChangeContent extends DirCacheEditor.PathEdit {
-
+    private final String filePath;
     private final RawInput newContent;
     private final Repository repository;
     private final Integer newGitFileMode;
@@ -85,13 +88,17 @@
         Repository repository,
         @Nullable Integer newGitFileMode) {
       super(filePath);
+      this.filePath = filePath;
       this.newContent = newContent;
       this.repository = repository;
       this.newGitFileMode = newGitFileMode;
     }
 
     private boolean isValidGitFileMode(int gitFileMode) {
-      return (gitFileMode == EXECUTABLE_FILE.getMode()) || (gitFileMode == REGULAR_FILE.getMode());
+      return gitFileMode == EXECUTABLE_FILE.getMode()
+          || gitFileMode == REGULAR_FILE.getMode()
+          || gitFileMode == GITLINK.getMode()
+          || gitFileMode == SYMLINK.getMode();
     }
 
     @Override
@@ -99,15 +106,24 @@
       try {
         if (newGitFileMode != null && newGitFileMode != 0) {
           if (!isValidGitFileMode(newGitFileMode)) {
-            throw new IllegalStateException("GitFileMode " + newGitFileMode + " is invalid");
+            throw new InvalidFileModeException(
+                String.format("GitFileMode %s is invalid", newGitFileMode), newGitFileMode);
           }
-          dirCacheEntry.setFileMode(FileMode.fromBits(newGitFileMode));
+
+          FileMode fileMode = FileMode.fromBits(newGitFileMode);
+          dirCacheEntry.setFileMode(fileMode);
         }
         if (dirCacheEntry.getFileMode() == FileMode.GITLINK) {
           dirCacheEntry.setLength(0);
           dirCacheEntry.setLastModified(Instant.EPOCH);
-          ObjectId newObjectId = ObjectId.fromString(getNewContentBytes(), 0);
-          dirCacheEntry.setObjectId(newObjectId);
+
+          try {
+            ObjectId newObjectId = ObjectId.fromString(getNewContentBytes(), 0);
+            dirCacheEntry.setObjectId(newObjectId);
+          } catch (InvalidObjectIdException e) {
+            throw new InvalidGitLinkException(
+                String.format("The content for gitlink '%s' must be a valid SHA1.", filePath), e);
+          }
         } else {
           if (dirCacheEntry.getRawMode() == 0) {
             dirCacheEntry.setFileMode(FileMode.REGULAR_FILE);
diff --git a/java/com/google/gerrit/server/edit/tree/InvalidFileModeException.java b/java/com/google/gerrit/server/edit/tree/InvalidFileModeException.java
new file mode 100644
index 0000000..3578dfc
--- /dev/null
+++ b/java/com/google/gerrit/server/edit/tree/InvalidFileModeException.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2025 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;
+
+/** Exception to be throw if an invalid or unsupported file mode is being used. */
+public class InvalidFileModeException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  private final int invalidFileMode;
+
+  InvalidFileModeException(String message, int invalidFileMode) {
+    super(message);
+    this.invalidFileMode = invalidFileMode;
+  }
+
+  public int getInvalidFileMode() {
+    return invalidFileMode;
+  }
+}
diff --git a/java/com/google/gerrit/server/edit/tree/InvalidGitLinkException.java b/java/com/google/gerrit/server/edit/tree/InvalidGitLinkException.java
new file mode 100644
index 0000000..ae42805
--- /dev/null
+++ b/java/com/google/gerrit/server/edit/tree/InvalidGitLinkException.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2025 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;
+
+/** Exception to be throw if the content of a gitlink is invalid (i.e. if it's not a SHA1). */
+public class InvalidGitLinkException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  InvalidGitLinkException(String message, Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 4c1f7e3..edc0846 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -18,7 +18,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Comparator.naturalOrder;
 import static java.util.stream.Collectors.joining;
@@ -121,13 +120,6 @@
 public class MergeUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  /**
-   * Length of abbreviated hex SHA-1s in merged filenames.
-   *
-   * <p>This is a constant so output is stable over time even if the SHA-1 prefix becomes ambiguous.
-   */
-  private static final int NAME_ABBREV_LEN = 6;
-
   private static final String R_HEADS_MASTER = Constants.R_HEADS + Constants.MASTER;
 
   public static boolean useRecursiveMerge(Config cfg) {
@@ -360,13 +352,13 @@
         String.format(
             "%-" + nameLength + "s (%s %s)",
             oursName,
-            abbreviateName(ours, NAME_ABBREV_LEN),
+            ours.getName(),
             oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
     String theirsNameFormatted =
         String.format(
             "%-" + nameLength + "s (%s %s)",
             theirsName,
-            abbreviateName(theirs, NAME_ABBREV_LEN),
+            theirs.getName(),
             theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
 
     MergeFormatter fmt = new MergeFormatter();
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index cc4626e..e254727 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.entities.Patch.FileMode.EXECUTABLE_FILE;
-import static com.google.gerrit.entities.Patch.FileMode.REGULAR_FILE;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -337,22 +335,12 @@
     }
 
     @Nullable
-    private Integer decimalAsOctal(Integer inputMode) throws BadRequestException {
+    private Integer octalAsDecimal(Integer inputMode) {
       if (inputMode == null) {
         return null;
       }
 
-      switch (inputMode) {
-        case 100755 -> {
-          return EXECUTABLE_FILE.getMode();
-        }
-        case 100644 -> {
-          return REGULAR_FILE.getMode();
-        }
-      }
-
-      throw new BadRequestException(
-          "file_mode (" + inputMode + ") was invalid: supported values are 100644 or 100755.");
+      return Integer.parseInt(Integer.toString(inputMode), 8);
     }
 
     public Response<Object> apply(
@@ -363,13 +351,13 @@
             IOException,
             PermissionBackendException {
 
-      if (fileContentInput.content == null && fileContentInput.binary_content == null) {
+      if (fileContentInput.content == null && fileContentInput.binaryContent == null) {
         throw new BadRequestException("either content or binary_content is required");
       }
 
       RawInput newContent;
-      if (fileContentInput.binary_content != null) {
-        Matcher m = BINARY_DATA_PATTERN.matcher(fileContentInput.binary_content);
+      if (fileContentInput.binaryContent != null) {
+        Matcher m = BINARY_DATA_PATTERN.matcher(fileContentInput.binaryContent);
         if (m.matches() && BASE64.equals(m.group(2))) {
           newContent = RawInputUtil.create(Base64.decode(m.group(3)));
         } else {
@@ -379,7 +367,7 @@
         newContent = fileContentInput.content;
       }
 
-      if (Patch.COMMIT_MSG.equals(path) && fileContentInput.binary_content == null) {
+      if (Patch.COMMIT_MSG.equals(path) && fileContentInput.binaryContent == null) {
         EditMessage.Input editMessageInput = new EditMessage.Input();
         editMessageInput.message =
             new String(ByteStreams.toByteArray(newContent.getInputStream()), UTF_8);
@@ -396,7 +384,7 @@
             rsrc.getNotes(),
             path,
             newContent,
-            decimalAsOctal(fileContentInput.fileMode));
+            octalAsDecimal(fileContentInput.fileMode));
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 34220b0..c1d31ca 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -152,6 +152,11 @@
           inserter.setMessage(
               String.format("Patch Set %s: Commit message was updated.", psId.getId()));
           inserter.setDescription("Edit commit message");
+
+          // Copy the conflicts information if present. Amending the commit to set a new commit
+          // message doesn't change anything about the conflicts.
+          ps.conflicts().ifPresent(conflicts -> inserter.setConflicts(conflicts));
+
           bu.setNotify(resolveNotify(input, resource));
           bu.addOp(resource.getChange().getId(), inserter);
           bu.execute();
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index e85a032..e11d856 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -290,6 +290,24 @@
     verifyNoMoreInteractions(cache, indexer, includeCache);
   }
 
+  @Test
+  public void newCreateProjectsAreDeleted() throws Exception {
+    Project.NameKey project2 = Project.nameKey("bar");
+
+    ProjectCache projectCache = mock(ProjectCache.class);
+
+    try (ProjectResetter resetProject =
+        builder(null, null, null, null, null, null, projectCache)
+            .build(new ProjectResetter.Config.Builder().deleteNewProjects().build())) {
+      Repository repo2 = repoManager.createRepository(project2);
+      createRef("refs/heads/master");
+      createRef(repo2, RefNames.REFS_CONFIG);
+    }
+
+    assertThat(repoManager.list()).doesNotContain(project2);
+    verify(projectCache, only()).evictAndReindex(project2);
+  }
+
   @CanIgnoreReturnValue
   private Ref createRef(String ref) throws IOException {
     return createRef(repo, ref);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 22b8180..685e4df 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -115,6 +115,7 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.ChangeIdentifier;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -238,7 +239,6 @@
 @UseTimezone(timezone = "US/Eastern")
 @VerifyNoPiiInChangeNotes(true)
 public class ChangeIT extends AbstractDaemonTest {
-
   @Inject private AccountOperations accountOperations;
   @Inject private ChangeIndexCollection changeIndexCollection;
   @Inject private GroupOperations groupOperations;
@@ -264,105 +264,114 @@
   public void get() throws Exception {
     TestChange change = changeOperations.newChange().createAndGet();
 
-    ChangeInfo c = gApi.changes().id(change.id()).info();
+    ChangeInfo changeInfo = gApi.changes().id(change.id()).info();
 
-    assertThat(c.id).isEqualTo(change.project() + "~" + change.numericChangeId());
-    assertThat(c.project).isEqualTo(change.project().get());
-    assertThat(c.branch).isEqualTo(change.dest().shortName());
-    assertThat(c.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(c.subject).isEqualTo(change.subject());
-    assertThat(c.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
-    assertThat(c.mergeable).isNull();
-    assertThat(c.changeId).isEqualTo(change.changeId());
-    assertThat(c._number).isEqualTo(change.numericChangeId().get());
-    assertThat(c.currentRevisionNumber)
+    assertThat(changeInfo.id).isEqualTo(change.project() + "~" + change.numericChangeId());
+    assertThat(changeInfo.project).isEqualTo(change.project().get());
+    assertThat(changeInfo.branch).isEqualTo(change.dest().shortName());
+    assertThat(changeInfo.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(changeInfo.subject).isEqualTo(change.subject());
+    assertThat(changeInfo.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
+    assertThat(changeInfo.mergeable).isNull();
+    assertThat(changeInfo.changeId).isEqualTo(change.changeId());
+    assertThat(changeInfo._number).isEqualTo(change.numericChangeId().get());
+    assertThat(changeInfo.currentRevisionNumber)
         .isEqualTo(changeOperations.change(change.id()).currentPatchset().get().patchsetId().get());
 
     // With NoteDb timestamps are rounded to seconds.
-    assertThat(c.created)
+    assertThat(changeInfo.created)
         .isEqualTo(Timestamp.from(change.createdOn().truncatedTo(ChronoUnit.SECONDS)));
-    assertThat(c.created).isEqualTo(c.updated);
+    assertThat(changeInfo.created).isEqualTo(changeInfo.updated);
 
-    assertThat(c.owner._accountId).isEqualTo(change.owner().get());
-    assertThat(c.owner.name).isNull();
-    assertThat(c.owner.email).isNull();
-    assertThat(c.owner.username).isNull();
-    assertThat(c.owner.avatars).isNull();
-    assertThat(c.submissionId).isNull();
+    assertThat(changeInfo.owner._accountId).isEqualTo(change.owner().get());
+    assertThat(changeInfo.owner.name).isNull();
+    assertThat(changeInfo.owner.email).isNull();
+    assertThat(changeInfo.owner.username).isNull();
+    assertThat(changeInfo.owner.avatars).isNull();
+    assertThat(changeInfo.submissionId).isNull();
   }
 
   @Test
   public void cannotGetInvisibleChange() throws Exception {
-    PushOneCommit.Result r = createChange();
+    TestChange change = changeOperations.newChange().createAndGet();
 
     // Remove read access
     projectOperations
-        .project(project)
+        .project(change.project())
         .forUpdate()
         .add(block(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
         .update();
 
     requestScopeOperations.setApiUser(user.id());
     ResourceNotFoundException thrown =
-        assertThrows(
-            ResourceNotFoundException.class,
-            () -> gApi.changes().id(project.get(), r.getChange().getId().get()).get());
+        assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(change.id()).get());
     assertThat(thrown)
         .hasMessageThat()
-        .isEqualTo(String.format("Not found: %s~%d", project.get(), r.getChange().getId().get()));
+        .isEqualTo(
+            String.format(
+                "Not found: %s~%d", change.project().get(), change.numericChangeId().get()));
   }
 
   @Test
   public void adminCanGetChangeWithoutExplicitReadPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
+    TestChange change = changeOperations.newChange().createAndGet();
 
     // Remove read access
     projectOperations
-        .project(project)
+        .project(change.project())
         .forUpdate()
         .add(block(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
         .update();
 
     requestScopeOperations.setApiUser(admin.id());
-    ChangeInfo changeInfo = gApi.changes().id(project.get(), r.getChange().getId().get()).get();
+    ChangeInfo changeInfo = gApi.changes().id(change.id()).get();
     assertThat(changeInfo.id)
-        .isEqualTo(String.format("%s~%d", project.get(), r.getChange().getId().get()));
+        .isEqualTo(String.format("%s~%d", change.project().get(), change.numericChangeId().get()));
   }
 
   @Test
   public void diffStatShouldComputeInsertionsAndDeletions() throws Exception {
-    String fileName = "a_new_file.txt";
-    String fileContent = "First line\nSecond line\n";
-    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
-    String triplet = project.get() + "~master~" + result.getChangeId();
-    ChangeInfo change = gApi.changes().id(triplet).get();
-    assertThat(change.insertions).isNotNull();
-    assertThat(change.deletions).isNotNull();
+    ChangeIdentifier changeIdentifier =
+        changeOperations
+            .newChange()
+            .file("a_new_file.txt")
+            .content("First line\nSecond line\n")
+            .createV2();
+
+    ChangeInfo changeInfo = gApi.changes().id(changeIdentifier).get();
+    assertThat(changeInfo.insertions).isEqualTo(2);
+    assertThat(changeInfo.deletions).isEqualTo(0);
   }
 
   @Test
   public void diffStatShouldSkipInsertionsAndDeletions() throws Exception {
-    String fileName = "a_new_file.txt";
-    String fileContent = "First line\nSecond line\n";
-    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
-    String triplet = project.get() + "~master~" + result.getChangeId();
-    ChangeInfo change =
-        gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
-    assertThat(change.insertions).isNull();
-    assertThat(change.deletions).isNull();
+    ChangeIdentifier changeIdentifier =
+        changeOperations
+            .newChange()
+            .file("a_new_file.txt")
+            .content("First line\nSecond line\n")
+            .createV2();
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeIdentifier).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
+    assertThat(changeInfo.insertions).isNull();
+    assertThat(changeInfo.deletions).isNull();
   }
 
   @Test
   public void skipDiffstatOptionAvoidsAllDiffComputations() throws Exception {
-    String fileName = "a_new_file.txt";
-    String fileContent = "First line\nSecond line\n";
-    PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
-    String triplet = project.get() + "~master~" + result.getChangeId();
+    ChangeIdentifier changeIdentifier =
+        changeOperations
+            .newChange()
+            .file("a_new_file.txt")
+            .content("First line\nSecond line\n")
+            .createV2();
+
     CacheStats startIntra = cloneStats(intraCache.stats());
     CacheStats startSummary = cloneStats(diffSummaryCache.stats());
 
     @SuppressWarnings("unused")
-    var unused = gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
+    var unused =
+        gApi.changes().id(changeIdentifier).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
 
     assertThat(intraCache.stats()).since(startIntra).hasMissCount(0);
     assertThat(intraCache.stats()).since(startIntra).hasHitCount(0);
@@ -373,57 +382,57 @@
   @Test
   @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void excludeMergeableInChangeInfo() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
+
+    ChangeInfo c = gApi.changes().id(changeIdentifier).get();
     assertThat(c.mergeable).isNull();
   }
 
   @Test
   public void getSubmissionId() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
 
-    merge(changeResult);
-    assertThat(gApi.changes().id(changeId).get().submissionId).isNotNull();
+    assertThat(gApi.changes().id(changeIdentifier).get().submissionId).isNull();
+
+    gApi.changes().id(changeIdentifier).current().review(ReviewInput.approve());
+    gApi.changes().id(changeIdentifier).current().submit();
+
+    assertThat(gApi.changes().id(changeIdentifier).get().submissionId).isNotNull();
   }
 
   @Test
   public void setWorkInProgressNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result rwip = createChange();
-    String changeId = rwip.getChangeId();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
 
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setWorkInProgress());
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(changeIdentifier).setWorkInProgress());
     assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
   public void setWorkInProgressAllowedAsAdmin() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
-    String changeId =
-        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(user.id()).createV2();
 
     requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(changeId).setWorkInProgress();
-    assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
+    gApi.changes().id(changeIdentifier).setWorkInProgress();
+    assertThat(gApi.changes().id(changeIdentifier).get().workInProgress).isTrue();
   }
 
   @Test
   public void setWorkInProgressAllowedAsProjectOwner() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
-    String changeId =
-        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
+    TestChange change = changeOperations.newChange().owner(user.id()).createAndGet();
 
     com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
     projectOperations
-        .project(project)
+        .project(change.project())
         .forUpdate()
         .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
         .update();
     requestScopeOperations.setApiUser(user2.id());
-    gApi.changes().id(changeId).setWorkInProgress();
-    assertThat(gApi.changes().id(changeId).get().workInProgress).isTrue();
+    gApi.changes().id(change.id()).setWorkInProgress();
+    assertThat(gApi.changes().id(change.id()).get().workInProgress).isTrue();
   }
 
   @Test
@@ -438,54 +447,52 @@
 
   @Test
   public void setReadyForReviewNotAllowedWithoutPermission() throws Exception {
-    PushOneCommit.Result rready = createChange();
-    String changeId = rready.getChangeId();
-    gApi.changes().id(changeId).setWorkInProgress();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
+    gApi.changes().id(changeIdentifier).setWorkInProgress();
 
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).setReadyForReview());
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(changeIdentifier).setReadyForReview());
     assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
   public void setReadyForReviewAllowedAsAdmin() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
-    String changeId =
-        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
-    gApi.changes().id(changeId).setWorkInProgress();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(user.id()).createV2();
+    gApi.changes().id(changeIdentifier).setWorkInProgress();
 
     requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(changeId).setReadyForReview();
-    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+    gApi.changes().id(changeIdentifier).setReadyForReview();
+    assertThat(gApi.changes().id(changeIdentifier).get().workInProgress).isNull();
   }
 
   @Test
   public void setReadyForReviewAllowedAsProjectOwner() throws Exception {
-    requestScopeOperations.setApiUser(user.id());
-    String changeId =
-        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get().id;
-    gApi.changes().id(changeId).setWorkInProgress();
+    TestChange change = changeOperations.newChange().owner(user.id()).createAndGet();
 
-    com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(change.id()).setWorkInProgress();
+
+    TestAccount user2 = accountCreator.user2();
     projectOperations
-        .project(project)
+        .project(change.project())
         .forUpdate()
         .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
         .update();
     requestScopeOperations.setApiUser(user2.id());
-    gApi.changes().id(changeId).setReadyForReview();
-    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+    gApi.changes().id(change.id()).setReadyForReview();
+    assertThat(gApi.changes().id(change.id()).get().workInProgress).isNull();
   }
 
   @Test
   public void setReadyForReviewSendsNotificationsForRevertChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
+    gApi.changes().id(changeIdentifier).current().review(ReviewInput.approve());
+    gApi.changes().id(changeIdentifier).current().submit();
     RevertInput in = new RevertInput();
     in.workInProgress = true;
-    String changeId = gApi.changes().id(r.getChangeId()).revert(in).get().changeId;
+    String changeId = gApi.changes().id(changeIdentifier).revert(in).get().changeId;
     requestScopeOperations.setApiUser(admin.id());
 
     gApi.changes().id(changeId).setReadyForReview();
@@ -497,7 +504,7 @@
     // 3. Change has been successfully merged by Administrator
     // 4. Patch Set 1: Reverted
     List<ChangeMessageInfo> sourceMessages =
-        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+        new ArrayList<>(gApi.changes().id(changeIdentifier).get().messages);
     assertThat(sourceMessages).hasSize(4);
     String expectedMessage = String.format("Created a revert of this change as %s", changeId);
     assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
@@ -521,9 +528,10 @@
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
 
-    PushOneCommit.Result r = createWorkInProgressChange();
-    String changeId = r.getChangeId();
-    assertThat(gApi.changes().id(changeId).get().pendingReviewers).isEmpty();
+    ChangeIdentifier changeIdentifier =
+        changeOperations.newChange().project(project).owner(admin.id()).createV2();
+    gApi.changes().id(changeIdentifier).setWorkInProgress();
+    assertThat(gApi.changes().id(changeIdentifier).get().pendingReviewers).isEmpty();
 
     // Add some pending reviewers.
     String email1 = name("user1") + "@example.com";
@@ -564,10 +572,10 @@
             .reviewer("byemail2@example.com")
             .reviewer("byemail3@example.com", CC, false)
             .reviewer("byemail4@example.com", CC, false);
-    ReviewResult result = gApi.changes().id(changeId).current().review(in);
+    ReviewResult result = gApi.changes().id(changeIdentifier).current().review(in);
     assertThat(result.changeInfo).isNotNull();
     assertThat(result.reviewers).isNotEmpty();
-    ChangeInfo info = gApi.changes().id(changeId).get();
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
     Function<Collection<AccountInfo>, Collection<String>> toEmails =
         ais -> ais.stream().map(ai -> ai.email).collect(toSet());
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
@@ -577,11 +585,11 @@
     assertThat(info.pendingReviewers.get(REMOVED)).isNull();
 
     // Stage some pending reviewer removals.
-    gApi.changes().id(changeId).reviewer(email1).remove();
-    gApi.changes().id(changeId).reviewer(email3).remove();
-    gApi.changes().id(changeId).reviewer("byemail1@example.com").remove();
-    gApi.changes().id(changeId).reviewer("byemail3@example.com").remove();
-    info = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeIdentifier).reviewer(email1).remove();
+    gApi.changes().id(changeIdentifier).reviewer(email3).remove();
+    gApi.changes().id(changeIdentifier).reviewer("byemail1@example.com").remove();
+    gApi.changes().id(changeIdentifier).reviewer("byemail3@example.com").remove();
+    info = gApi.changes().id(changeIdentifier).get();
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
         .containsExactly(email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
@@ -591,8 +599,8 @@
 
     // "Undo" a removal.
     in = ReviewInput.noScore().reviewer(email1);
-    gApi.changes().id(changeId).current().review(in);
-    info = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeIdentifier).current().review(in);
+    info = gApi.changes().id(changeIdentifier).get();
     assertThat(toEmails.apply(info.pendingReviewers.get(REVIEWER)))
         .containsExactly(email1, email2, "byemail2@example.com");
     assertThat(toEmails.apply(info.pendingReviewers.get(CC)))
@@ -601,8 +609,8 @@
         .containsExactly(email3, "byemail1@example.com", "byemail3@example.com");
 
     // "Commit" by moving out of WIP.
-    gApi.changes().id(changeId).setReadyForReview();
-    info = gApi.changes().id(changeId).get();
+    gApi.changes().id(changeIdentifier).setReadyForReview();
+    info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.pendingReviewers).isEmpty();
     assertThat(toEmails.apply(info.reviewers.get(REVIEWER)))
         .containsExactly(email1, email2, "byemail2@example.com");
@@ -613,37 +621,36 @@
 
   @Test
   public void toggleWorkInProgressState() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
 
     // With message
-    gApi.changes().id(changeId).setWorkInProgress("Needs some refactoring");
+    gApi.changes().id(changeIdentifier).setWorkInProgress("Needs some refactoring");
 
-    ChangeInfo info = gApi.changes().id(changeId).get();
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
 
     assertThat(info.workInProgress).isTrue();
     assertThat(Iterables.getLast(info.messages).message).contains("Needs some refactoring");
     assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
 
-    gApi.changes().id(changeId).setReadyForReview("PTAL");
+    gApi.changes().id(changeIdentifier).setReadyForReview("PTAL");
 
-    info = gApi.changes().id(changeId).get();
+    info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.workInProgress).isNull();
     assertThat(Iterables.getLast(info.messages).message).contains("PTAL");
     assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
 
     // No message
-    gApi.changes().id(changeId).setWorkInProgress();
+    gApi.changes().id(changeIdentifier).setWorkInProgress();
 
-    info = gApi.changes().id(changeId).get();
+    info = gApi.changes().id(changeIdentifier).get();
 
     assertThat(info.workInProgress).isTrue();
     assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Work In Progress");
     assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
 
-    gApi.changes().id(changeId).setReadyForReview();
+    gApi.changes().id(changeIdentifier).setReadyForReview();
 
-    info = gApi.changes().id(changeId).get();
+    info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.workInProgress).isNull();
     assertThat(Iterables.getLast(info.messages).message).isEqualTo("Set Ready For Review");
     assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
@@ -651,13 +658,12 @@
 
   @Test
   public void toggleWorkInProgressStateByNonOwnerWithPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
+    TestChange change = changeOperations.newChange().createAndGet();
     String refactor = "Needs some refactoring";
     String ptal = "PTAL";
 
     projectOperations
-        .project(project)
+        .project(change.project())
         .forUpdate()
         .add(
             allow(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
@@ -666,17 +672,17 @@
         .update();
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).setWorkInProgress(refactor);
+    gApi.changes().id(change.id()).setWorkInProgress(refactor);
 
-    ChangeInfo info = gApi.changes().id(changeId).get();
+    ChangeInfo info = gApi.changes().id(change.id()).get();
 
     assertThat(info.workInProgress).isTrue();
     assertThat(Iterables.getLast(info.messages).message).contains(refactor);
     assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_WIP);
 
-    gApi.changes().id(changeId).setReadyForReview(ptal);
+    gApi.changes().id(change.id()).setReadyForReview(ptal);
 
-    info = gApi.changes().id(changeId).get();
+    info = gApi.changes().id(change.id()).get();
     assertThat(info.workInProgress).isNull();
     assertThat(Iterables.getLast(info.messages).message).contains(ptal);
     assertThat(Iterables.getLast(info.messages).tag).contains(ChangeMessagesUtil.TAG_SET_READY);
@@ -684,44 +690,41 @@
 
   @Test
   public void reviewAndStartReview() throws Exception {
-    PushOneCommit.Result r = createWorkInProgressChange();
-    r.assertOkStatus();
-    assertThat(r.getChange().change().isWorkInProgress()).isTrue();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
+    gApi.changes().id(changeIdentifier).setWorkInProgress();
 
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(false);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
+    ReviewResult result = gApi.changes().id(changeIdentifier).current().review(in);
     assertThat(result.ready).isTrue();
 
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.workInProgress).isNull();
   }
 
   @Test
   public void reviewAndMoveToWorkInProgress() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
 
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
+    ReviewResult result = gApi.changes().id(changeIdentifier).current().review(in);
     assertThat(result.ready).isNull();
 
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.workInProgress).isTrue();
   }
 
   @Test
   public void reviewAndSetWorkInProgressAndAddReviewerAndVote() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
 
     ReviewInput in =
         ReviewInput.approve()
             .reviewer(user.email())
             .label(LabelId.CODE_REVIEW, 1)
             .setWorkInProgress(true);
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    gApi.changes().id(changeIdentifier).current().review(in);
 
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.workInProgress).isTrue();
     assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
         .containsExactly(admin.id().get(), user.id().get());
@@ -731,24 +734,24 @@
 
   @Test
   public void reviewRemoveInactiveReviewer() throws Exception {
-    PushOneCommit.Result r = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
     ReviewInput in = ReviewInput.approve().reviewer(user.email());
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    gApi.changes().id(changeIdentifier).current().review(in);
 
     accountOperations.account(user.id()).forUpdate().inactive().update();
     in = ReviewInput.noScore().reviewer(Integer.toString(user.id().get()), REMOVED, false);
 
-    gApi.changes().id(r.getChangeId()).current().review(in);
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    gApi.changes().id(changeIdentifier).current().review(in);
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
         .containsExactly(admin.id().get());
   }
 
   @Test
   public void removeReviewerWithoutPermissionsOnChangePostReview_allowed() throws Exception {
-    PushOneCommit.Result r = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
     ReviewInput in = ReviewInput.approve().reviewer(user.email());
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    gApi.changes().id(changeIdentifier).current().review(in);
     AccountGroup.UUID restrictedGroup =
         groupOperations.newGroup().name("restricted-group").addMember(user.id()).create();
 
@@ -762,18 +765,17 @@
     in = ReviewInput.noScore().reviewer(Integer.toString(user.id().get()), REMOVED, false);
 
     requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(r.getChangeId()).current().review(in);
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    gApi.changes().id(changeIdentifier).current().review(in);
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
         .containsExactly(admin.id().get());
   }
 
   @Test
   public void removeReviewerWithoutPermissionsOnChange_allowed() throws Exception {
-
-    PushOneCommit.Result r = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
     ReviewInput in = ReviewInput.approve().reviewer(user.email());
-    gApi.changes().id(r.getChangeId()).current().review(in);
+    gApi.changes().id(changeIdentifier).current().review(in);
     AccountGroup.UUID restrictedGroup =
         groupOperations.newGroup().name("restricted-group").addMember(user.id()).create();
 
@@ -785,69 +787,61 @@
         .update();
 
     requestScopeOperations.setApiUser(admin.id());
-    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).remove();
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    gApi.changes().id(changeIdentifier).reviewer(user.id().toString()).remove();
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
         .containsExactly(admin.id().get());
   }
 
   @Test
   public void reviewWithWorkInProgressAndReadyReturnsError() throws Exception {
-    PushOneCommit.Result r = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
     ReviewInput in = ReviewInput.noScore();
     in.ready = true;
     in.workInProgress = true;
-    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
+    ReviewResult result = gApi.changes().id(changeIdentifier).current().review(in);
     assertThat(result.error).isEqualTo(PostReview.ERROR_WIP_READY_MUTUALLY_EXCLUSIVE);
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user1")
   public void reviewWithWorkInProgressChangeOwner() throws Exception {
-    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id());
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(user.id()).createV2();
 
     requestScopeOperations.setApiUser(user.id());
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    gApi.changes().id(r.getChangeId()).current().review(in);
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    gApi.changes().id(changeIdentifier).current().review(in);
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.workInProgress).isTrue();
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user1")
   public void reviewWithWithWorkInProgressAdmin() throws Exception {
-    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    assertThat(r.getChange().change().getOwner()).isEqualTo(user.id());
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(user.id()).createV2();
 
     requestScopeOperations.setApiUser(admin.id());
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
-    gApi.changes().id(r.getChangeId()).current().review(in);
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    gApi.changes().id(changeIdentifier).current().review(in);
+    ChangeInfo info = gApi.changes().id(changeIdentifier).get();
     assertThat(info.workInProgress).isTrue();
   }
 
   @Test
   public void reviewWithWorkInProgressByNonOwnerReturnsError() throws Exception {
-    PushOneCommit.Result r = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(admin.id()).createV2();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(
-            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+            AuthException.class, () -> gApi.changes().id(changeIdentifier).current().review(in));
     assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
   public void reviewWithWorkInProgressByNonOwnerWithPermission() throws Exception {
-    PushOneCommit.Result r = createChange();
+    TestChange change = changeOperations.newChange().owner(admin.id()).createAndGet();
     ReviewInput in = ReviewInput.noScore().setWorkInProgress(true);
     projectOperations
-        .project(project)
+        .project(change.project())
         .forUpdate()
         .add(
             allow(Permission.TOGGLE_WORK_IN_PROGRESS_STATE)
@@ -855,50 +849,49 @@
                 .group(REGISTERED_USERS))
         .update();
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).current().review(in);
-    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    gApi.changes().id(change.id()).current().review(in);
+    ChangeInfo info = gApi.changes().id(change.id()).get();
     assertThat(info.workInProgress).isTrue();
   }
 
   @Test
   public void reviewWithReadyByNonOwnerReturnsError() throws Exception {
-    PushOneCommit.Result r = createChange();
-    change(r).setWorkInProgress();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(admin.id()).createV2();
+    gApi.changes().id(changeIdentifier).setWorkInProgress();
 
     ReviewInput in = ReviewInput.noScore().setReady(true);
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(
-            AuthException.class, () -> gApi.changes().id(r.getChangeId()).current().review(in));
+            AuthException.class, () -> gApi.changes().id(changeIdentifier).current().review(in));
     assertThat(thrown).hasMessageThat().contains("toggle work in progress state not permitted");
   }
 
   @Test
   public void getAmbiguous() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    String changeId = r1.getChangeId();
+    TestChange change =
+        changeOperations.newChange().project(project).branch("master").createAndGet();
 
     @SuppressWarnings("unused")
-    var unused = gApi.changes().id(changeId).get();
+    var unused = gApi.changes().id(change.id()).get();
 
     BranchInput b = new BranchInput();
     b.revision = repo().exactRef("HEAD").getObjectId().name();
     gApi.projects().name(project.get()).branch("other").create(b);
 
-    PushOneCommit push2 =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            PushOneCommit.FILE_CONTENT,
-            changeId);
-    PushOneCommit.Result r2 = push2.to("refs/for/other");
-    assertThat(r2.getChangeId()).isEqualTo(changeId);
+    TestChange otherChange =
+        changeOperations
+            .newChange()
+            .project(project)
+            .branch("other")
+            .commitMessage("Summary line\n\nChange-Id: " + change.changeId())
+            .createAndGet();
+    assertThat(otherChange.changeId()).isEqualTo(change.changeId());
 
     ResourceNotFoundException thrown =
-        assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(changeId).get());
-    assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + changeId);
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.changes().id(change.changeId()).get());
+    assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + change.changeId());
   }
 
   @Test
@@ -907,15 +900,12 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user1")
   public void deleteNewChangeAsNormalUser() throws Exception {
-    PushOneCommit.Result changeResult =
-        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    String changeId = changeResult.getChangeId();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(user.id()).createV2();
 
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeIdentifier).delete());
     assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
@@ -926,7 +916,7 @@
         .forUpdate()
         .add(allow(Permission.DELETE_CHANGES).ref("refs/*").group(REGISTERED_USERS))
         .update();
-    deleteChangeAsUser(admin, user);
+    deleteChangeAsUser(project, admin, user);
   }
 
   @Test
@@ -958,7 +948,7 @@
         .forUpdate()
         .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(REGISTERED_USERS))
         .update();
-    deleteChangeAsUser(user, user);
+    deleteChangeAsUser(project, user, user);
   }
 
   @Test
@@ -968,7 +958,7 @@
         .forUpdate()
         .add(allow(Permission.DELETE_OWN_CHANGES).ref("refs/*").group(CHANGE_OWNER))
         .update();
-    deleteChangeAsUser(user, user);
+    deleteChangeAsUser(project, user, user);
   }
 
   private void deleteChangeAsUser(
@@ -989,26 +979,24 @@
           .forUpdate()
           .add(allow(Permission.VIEW_PRIVATE_CHANGES).ref("refs/*").group(ANONYMOUS_USERS))
           .update();
+      TestChange change =
+          changeOperations.newChange().project(projectName).owner(owner.id()).createAndGet();
       requestScopeOperations.setApiUser(owner.id());
-      ChangeInput in = new ChangeInput();
-      in.project = projectName.get();
-      in.branch = "refs/heads/master";
-      in.subject = "test";
-      ChangeInfo changeInfo = gApi.changes().create(in).get();
-      String changeId = changeInfo.changeId;
-      int id = changeInfo._number;
-      String commit = changeInfo.currentRevision;
 
-      assertThat(gApi.changes().id(changeId).info().owner._accountId).isEqualTo(owner.id().get());
+      String commit =
+          changeOperations.change(change.id()).currentPatchset().get().commitId().getName();
+
+      assertThat(gApi.changes().id(change.id()).info().owner._accountId)
+          .isEqualTo(owner.id().get());
 
       requestScopeOperations.setApiUser(deleteAs.id());
-      gApi.changes().id(changeId).delete();
+      gApi.changes().id(change.id()).delete();
 
-      assertThat(query(changeId)).isEmpty();
+      assertThat(query(change.id().id())).isEmpty();
 
-      String ref = Change.id(id).toRefPrefix() + "1";
+      String ref = change.numericChangeId().toRefPrefix() + "1";
       eventRecorder.assertRefUpdatedEvents(projectName.get(), ref, null, commit, commit, null);
-      eventRecorder.assertChangeDeletedEvents(changeId, deleteAs.email());
+      eventRecorder.assertChangeDeletedEvents(change.changeId(), deleteAs.email());
     } finally {
       projectOperations
           .project(project)
@@ -1033,12 +1021,11 @@
         .update();
 
     try {
-      PushOneCommit.Result changeResult = createChange();
-      String changeId = changeResult.getChangeId();
+      ChangeIdentifier changeIdentifier = changeOperations.newChange().project(project).createV2();
 
       requestScopeOperations.setApiUser(user.id());
       AuthException thrown =
-          assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+          assertThrows(AuthException.class, () -> gApi.changes().id(changeIdentifier).delete());
       assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
       projectOperations
@@ -1052,57 +1039,50 @@
   @Test
   @TestProjectInput(createEmptyCommit = false)
   public void deleteNewChangeForBranchWithoutCommits() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().project(project).createV2();
 
-    gApi.changes().id(changeId).delete();
+    gApi.changes().id(changeIdentifier).delete();
 
-    assertThat(query(changeId)).isEmpty();
+    assertThat(query(changeIdentifier.id())).isEmpty();
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user1")
   public void deleteAbandonedChangeAsNormalUser() throws Exception {
-    PushOneCommit.Result changeResult =
-        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    String changeId = changeResult.getChangeId();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(user.id()).createV2();
 
     requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).abandon();
+    gApi.changes().id(changeIdentifier).abandon();
 
     AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).delete());
+        assertThrows(AuthException.class, () -> gApi.changes().id(changeIdentifier).delete());
     assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user1")
   public void deleteAbandonedChangeOfAnotherUserAsAdmin() throws Exception {
-    PushOneCommit.Result changeResult =
-        pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-    String changeId = changeResult.getChangeId();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(user.id()).createV2();
 
-    gApi.changes().id(changeId).abandon();
+    gApi.changes().id(changeIdentifier).abandon();
 
-    gApi.changes().id(changeId).delete();
+    gApi.changes().id(changeIdentifier).delete();
 
-    assertThat(query(changeId)).isEmpty();
+    assertThat(query(changeIdentifier.id())).isEmpty();
   }
 
   @Test
   public void deleteMergedChange() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
 
-    merge(changeResult);
+    gApi.changes().id(changeIdentifier).current().review(ReviewInput.approve());
+    gApi.changes().id(changeIdentifier).current().submit();
 
     MethodNotAllowedException thrown =
-        assertThrows(MethodNotAllowedException.class, () -> gApi.changes().id(changeId).delete());
+        assertThrows(
+            MethodNotAllowedException.class, () -> gApi.changes().id(changeIdentifier).delete());
     assertThat(thrown).hasMessageThat().contains("delete not permitted");
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user1")
   public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
     projectOperations
         .project(project)
@@ -1111,15 +1091,15 @@
         .update();
 
     try {
-      PushOneCommit.Result changeResult =
-          pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-      String changeId = changeResult.getChangeId();
+      ChangeIdentifier changeIdentifier = changeOperations.newChange().project(project).createV2();
 
-      merge(changeResult);
+      gApi.changes().id(changeIdentifier).current().review(ReviewInput.approve());
+      gApi.changes().id(changeIdentifier).current().submit();
 
       requestScopeOperations.setApiUser(user.id());
       MethodNotAllowedException thrown =
-          assertThrows(MethodNotAllowedException.class, () -> gApi.changes().id(changeId).delete());
+          assertThrows(
+              MethodNotAllowedException.class, () -> gApi.changes().id(changeIdentifier).delete());
       assertThat(thrown).hasMessageThat().contains("delete not permitted");
     } finally {
       projectOperations
@@ -1132,40 +1112,47 @@
 
   @Test
   public void deleteNewChangeWithMergedPatchSet() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
+    TestChange change = changeOperations.newChange().project(project).createAndGet();
 
-    merge(changeResult);
-    setChangeStatus(id, Change.Status.NEW);
+    gApi.changes().id(change.id()).current().review(ReviewInput.approve());
+    gApi.changes().id(change.id()).current().submit();
+
+    setChangeStatus(change.numericChangeId(), Change.Status.NEW);
 
     ResourceConflictException thrown =
-        assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).delete());
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(change.id()).delete());
     assertThat(thrown)
         .hasMessageThat()
-        .contains(String.format("Cannot delete change %s: patch set 1 is already merged", id));
+        .contains(
+            String.format(
+                "Cannot delete change %s: patch set 1 is already merged",
+                change.numericChangeId()));
   }
 
   @Test
   public void deleteChangeUpdatesIndex() throws Exception {
-    PushOneCommit.Result changeResult = createChange();
-    String changeId = changeResult.getChangeId();
-    Change.Id id = changeResult.getChange().getId();
+    TestChange change = changeOperations.newChange().project(project).createAndGet();
 
     ChangeIndex idx = changeIndexCollection.getSearchIndex();
 
     Optional<ChangeData> result =
-        idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
+        idx.get(
+            change.numericChangeId(),
+            IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
 
     assertThat(result).isPresent();
-    gApi.changes().id(changeId).delete();
-    result = idx.get(id, IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
+    gApi.changes().id(change.id()).delete();
+    result =
+        idx.get(
+            change.numericChangeId(),
+            IndexedChangeQuery.createOptions(indexConfig, 0, 1, ImmutableSet.of()));
     assertThat(result).isEmpty();
   }
 
   @Test
   public void deleteChangeRemovesDraftComment() throws Exception {
-    PushOneCommit.Result r = createChange();
+    TestChange change = changeOperations.newChange().createAndGet();
 
     requestScopeOperations.setApiUser(user.id());
 
@@ -1174,15 +1161,14 @@
     dri.path = "a.txt";
     dri.line = 1;
 
-    gApi.changes().id(r.getChangeId()).current().createDraft(dri);
-    Change.Id num = r.getChange().getId();
+    gApi.changes().id(change.id()).current().createDraft(dri);
 
-    assertThat(getDraftsCountForChange(num, user.id())).isGreaterThan(0);
+    assertThat(getDraftsCountForChange(change.numericChangeId(), user.id())).isGreaterThan(0);
 
     requestScopeOperations.setApiUser(admin.id());
 
-    gApi.changes().id(r.getChangeId()).delete();
-    assertThat(getDraftsCountForChange(num, user.id())).isEqualTo(0);
+    gApi.changes().id(change.id()).delete();
+    assertThat(getDraftsCountForChange(change.numericChangeId(), user.id())).isEqualTo(0);
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
@@ -1196,56 +1182,54 @@
 
   @Test
   public void deleteChangeRemovesItsChangeEdit() throws Exception {
-    PushOneCommit.Result result = createChange();
+    TestChange change = changeOperations.newChange().createAndGet();
 
     requestScopeOperations.setApiUser(user.id());
-    String changeId = result.getChangeId();
-    gApi.changes().id(changeId).edit().create();
+    gApi.changes().id(change.id()).edit().create();
     gApi.changes()
-        .id(changeId)
+        .id(change.id())
         .edit()
         .modifyFile(FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
 
     requestScopeOperations.setApiUser(admin.id());
-    String expected = RefNames.refsUsers(user.id()) + "/edit-" + result.getChange().getId() + "/1";
-    try (Repository repo = repoManager.openRepository(project)) {
+    String expected = RefNames.refsUsers(user.id()) + "/edit-" + change.numericChangeId() + "/1";
+    try (Repository repo = repoManager.openRepository(change.project())) {
       assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isNotEmpty();
-      gApi.changes().id(changeId).delete();
+      gApi.changes().id(change.id()).delete();
     }
     // On google infra, repo should be reopened for getting updated refs.
-    try (Repository repo = repoManager.openRepository(project)) {
+    try (Repository repo = repoManager.openRepository(change.project())) {
       assertThat(repo.getRefDatabase().getRefsByPrefix(expected)).isEmpty();
     }
   }
 
   @Test
   public void deleteChangeDoesntRemoveOtherChangeEdits() throws Exception {
-    PushOneCommit.Result result = createChange();
-    PushOneCommit.Result irrelevantChangeResult = createChange();
-    requestScopeOperations.setApiUser(admin.id());
-    String changeId = result.getChangeId();
-    String irrelevantChangeId = irrelevantChangeResult.getChangeId();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
+    ChangeIdentifier irrelevanetChangeIdentifier = changeOperations.newChange().createV2();
 
-    gApi.changes().id(irrelevantChangeId).edit().create();
+    requestScopeOperations.setApiUser(admin.id());
+
+    gApi.changes().id(irrelevanetChangeIdentifier).edit().create();
     gApi.changes()
-        .id(irrelevantChangeId)
+        .id(irrelevanetChangeIdentifier)
         .edit()
         .modifyFile(FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
 
-    gApi.changes().id(changeId).delete();
+    gApi.changes().id(changeIdentifier).delete();
 
-    assertThat(gApi.changes().id(irrelevantChangeId).edit().get()).isPresent();
+    assertThat(gApi.changes().id(irrelevanetChangeIdentifier).edit().get()).isPresent();
   }
 
   @Test
   public void attentionSetListener_firesOnChange() throws Exception {
-    PushOneCommit.Result r1 = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().createV2();
     TestAttentionSetListener attentionSetListener = new TestAttentionSetListener();
 
     try (Registration registration =
         extensionRegistry.newRegistration().add(attentionSetListener)) {
 
-      gApi.changes().id(r1.getChangeId()).addReviewer(user.email());
+      gApi.changes().id(changeIdentifier).addReviewer(user.email());
       assertThat(attentionSetListener.firedCount).isEqualTo(1);
       assertThat(attentionSetListener.lastEvent.usersAdded().size()).isEqualTo(1);
       attentionSetListener
@@ -1255,12 +1239,12 @@
 
       // Adding the user with the same reason doesn't fire an event.
       AttentionSetInput addUser = new AttentionSetInput(user.email(), "Reviewer was added");
-      gApi.changes().id(r1.getChangeId()).addToAttentionSet(addUser);
+      gApi.changes().id(changeIdentifier).addToAttentionSet(addUser);
       assertThat(attentionSetListener.firedCount).isEqualTo(1);
 
       // Adding the user with a different reason fires an event.
       addUser = new AttentionSetInput(user.email(), "some reason");
-      gApi.changes().id(r1.getChangeId()).addToAttentionSet(addUser);
+      gApi.changes().id(changeIdentifier).addToAttentionSet(addUser);
       assertThat(attentionSetListener.firedCount).isEqualTo(2);
       assertThat(attentionSetListener.lastEvent.usersAdded().size()).isEqualTo(1);
       attentionSetListener
@@ -1269,7 +1253,7 @@
           .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
 
       // Removing the user fires an event.
-      gApi.changes().id(r1.getChangeId()).attention(user.username()).remove(addUser);
+      gApi.changes().id(changeIdentifier).attention(user.username()).remove(addUser);
       assertThat(attentionSetListener.firedCount).isEqualTo(3);
       assertThat(attentionSetListener.lastEvent.usersAdded()).isEmpty();
       assertThat(attentionSetListener.lastEvent.usersRemoved().size()).isEqualTo(1);
@@ -1402,21 +1386,18 @@
         .update();
 
     // create change
-    TestRepository<InMemoryRepository> repo = cloneProject(p, admin);
-    PushOneCommit push = pushFactory.create(admin.newIdent(), repo);
-    PushOneCommit.Result result = push.to("refs/for/master");
-    result.assertOkStatus();
+    ChangeIdentifier changeIdentifier =
+        changeOperations.newChange().project(p).owner(admin.id()).createV2();
 
     // check the user cannot see the change
     requestScopeOperations.setApiUser(user.id());
-    assertThrows(
-        ResourceNotFoundException.class, () -> gApi.changes().id(result.getChangeId()).get());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(changeIdentifier).get());
 
     // try to add user as reviewer
     requestScopeOperations.setApiUser(admin.id());
     ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
-    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(changeIdentifier).addReviewer(in);
 
     assertThat(r.input).isEqualTo(user.email());
     assertThat(r.error).contains("does not have permission to see this change");
@@ -1425,14 +1406,14 @@
 
   @Test
   public void addReviewerThatIsInactiveByUsername() throws Exception {
-    PushOneCommit.Result result = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(admin.id()).createV2();
 
     String username = name("new-user");
     Account.Id id = accountOperations.newAccount().username(username).inactive().create();
 
     ReviewerInput in = new ReviewerInput();
     in.reviewer = username;
-    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(changeIdentifier).addReviewer(in);
 
     assertThat(r.input).isEqualTo(in.reviewer);
     assertThat(r.error).isNull();
@@ -1446,14 +1427,14 @@
 
   @Test
   public void addReviewerThatIsInactiveById() throws Exception {
-    PushOneCommit.Result result = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(admin.id()).createV2();
 
     String username = name("new-user");
     Account.Id id = accountOperations.newAccount().username(username).inactive().create();
 
     ReviewerInput in = new ReviewerInput();
     in.reviewer = Integer.toString(id.get());
-    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(changeIdentifier).addReviewer(in);
 
     assertThat(r.input).isEqualTo(in.reviewer);
     assertThat(r.error).isNull();
@@ -1470,14 +1451,14 @@
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
-    PushOneCommit.Result result = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(admin.id()).createV2();
     String email = "user@domain.com";
     Account.Id id = accountOperations.newAccount().preferredEmail(email).inactive().create();
 
     ReviewerInput in = new ReviewerInput();
     in.reviewer = email;
     in.state = ReviewerState.CC;
-    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(changeIdentifier).addReviewer(in);
 
     assertThat(r.input).isEqualTo(email);
     assertThat(r.error).isNull();
@@ -1491,10 +1472,10 @@
   @UseClockStep
   public void addReviewer() throws Exception {
     testAddReviewerViaPostReview(
-        (changeId, reviewer) -> {
+        (changeIdentifier, reviewer) -> {
           ReviewerInput in = new ReviewerInput();
           in.reviewer = reviewer;
-          gApi.changes().id(changeId).addReviewer(in);
+          gApi.changes().id(changeIdentifier).addReviewer(in);
         });
   }
 
@@ -1502,21 +1483,25 @@
   @UseClockStep
   public void addReviewerViaPostReview() throws Exception {
     testAddReviewerViaPostReview(
-        (changeId, reviewer) -> {
+        (changeIdentifier, reviewer) -> {
           ReviewerInput reviewerInput = new ReviewerInput();
           reviewerInput.reviewer = reviewer;
           ReviewInput reviewInput = new ReviewInput();
           reviewInput.reviewers = ImmutableList.of(reviewerInput);
-          gApi.changes().id(changeId).current().review(reviewInput);
+          gApi.changes().id(changeIdentifier).current().review(reviewInput);
         });
   }
 
   private void testAddReviewerViaPostReview(AddReviewerCaller addReviewer) throws Exception {
-    PushOneCommit.Result r = createChange();
-    ChangeResource rsrc = parseResource(r);
-    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
+    TestChange change =
+        changeOperations
+            .newChange()
+            .owner(admin.id())
+            .commitMessage(PushOneCommit.SUBJECT)
+            .createAndGet();
+    Instant oldTs = change.lastUpdatedOn();
 
-    addReviewer.call(r.getChangeId(), user.email());
+    addReviewer.call(change.id(), user.email());
 
     ImmutableList<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
@@ -1526,7 +1511,7 @@
     assertThat(m.body()).contains("I'd like you to do a code review.");
     assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
     assertMailReplyTo(m, admin.email());
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    ChangeInfo c = gApi.changes().id(change.id()).get();
 
     // Adding a reviewer records that user as reviewer.
     Collection<AccountInfo> reviewers = c.reviewers.get(REVIEWER);
@@ -1538,30 +1523,31 @@
     assertThat(c.reviewers.get(CC)).isNull();
 
     // Ensure lastUpdatedOn is updated.
-    rsrc = parseResource(r);
-    assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
+    assertThat(changeOperations.change(change.id()).get().lastUpdatedOn()).isNotEqualTo(oldTs);
   }
 
   @Test
   public void postingMessageOnOwnChangeDoesntAddCallerAsReviewer() throws Exception {
-    PushOneCommit.Result r = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(admin.id()).createV2();
 
+    requestScopeOperations.setApiUser(admin.id());
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.message = "Foo Bar";
-    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    gApi.changes().id(changeIdentifier).current().review(reviewInput);
 
-    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    ChangeInfo c = gApi.changes().id(changeIdentifier).get();
     assertThat(c.reviewers.get(REVIEWER)).isNull();
     assertThat(c.reviewers.get(CC)).isNull();
   }
 
   @Test
   public void listReviewers() throws Exception {
-    PushOneCommit.Result r = createChange();
+    ChangeIdentifier changeIdentifier = changeOperations.newChange().owner(admin.id()).createV2();
+
     ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-    assertThat(gApi.changes().id(r.getChangeId()).reviewers()).hasSize(1);
+    gApi.changes().id(changeIdentifier).addReviewer(in);
+    assertThat(gApi.changes().id(changeIdentifier).reviewers()).hasSize(1);
 
     String username1 = name("user1");
     String email1 = username1 + "@example.com";
@@ -1574,12 +1560,12 @@
             .create();
     in.reviewer = email1;
     in.state = ReviewerState.CC;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
+    gApi.changes().id(changeIdentifier).addReviewer(in);
     if (server.isUsernameSupported()) {
-      assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a.username))
+      assertThat(gApi.changes().id(changeIdentifier).reviewers().stream().map(a -> a.username))
           .containsExactly(user.username(), username1);
     }
-    assertThat(gApi.changes().id(r.getChangeId()).reviewers().stream().map(a -> a._accountId))
+    assertThat(gApi.changes().id(changeIdentifier).reviewers().stream().map(a -> a._accountId))
         .containsExactly(user.id().get(), user1Id.get());
   }
 
@@ -5010,7 +4996,7 @@
 
   @FunctionalInterface
   private interface AddReviewerCaller {
-    void call(String changeId, String reviewer) throws RestApiException;
+    void call(ChangeIdentifier changeIdentifier, String reviewer) throws RestApiException;
   }
 
   public static class TestAttentionSetListenerModule extends AbstractModule {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
index 710ae54..bde14a2 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -305,12 +304,10 @@
     ByteArrayOutputStream os = new ByteArrayOutputStream();
     bin.writeTo(os);
     String fileContent = new String(os.toByteArray(), UTF_8);
-    String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
-    String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
     assertThat(fileContent)
         .isEqualTo(
             "<<<<<<< TARGET BRANCH ("
-                + targetSha1
+                + currentMaster.getCommit().getName()
                 + " "
                 + targetSubject
                 + ")\n"
@@ -320,7 +317,7 @@
                 + sourceContent
                 + "\n"
                 + ">>>>>>> SOURCE BRANCH ("
-                + sourceSha1
+                + changeA.getCommit().getName()
                 + " "
                 + sourceSubject
                 + ")\n");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 30f500e..b05e7be 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -449,7 +449,8 @@
         accountOperations.newAccount().preferredEmail(reviewerEmail).create();
 
     // Create the change.
-    Change.Id changeId = changeOperations.newChange().owner(nonVisibleOwner).create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).owner(nonVisibleOwner).create();
 
     // Add a review.
     requestScopeOperations.setApiUser(nonVisibleReviewer);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index e63e8d7..f2433c2 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -23,7 +23,6 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
@@ -506,7 +505,6 @@
               .content(mergeContent)
               .create();
       String commitThatIsBeingRebased = getCurrentRevision(mergeChangeId);
-      String mergeSha1 = abbreviateName(ObjectId.fromString(commitThatIsBeingRebased), 6);
 
       // Create a change in master onto which the merge change can be rebased. This change touches
       // the file again so that there is a conflict on rebase.
@@ -549,11 +547,10 @@
           /* expectedNumRevisions= */ 2);
 
       // Verify the file contents.
-      String baseSha1 = abbreviateName(ObjectId.fromString(baseCommit), 6);
       assertThat(getFileContent(mergeChangeId, file))
           .isEqualTo(
               "<<<<<<< PATCH SET ("
-                  + mergeSha1
+                  + commitThatIsBeingRebased
                   + " "
                   + mergeCommitMessage
                   + ")\n"
@@ -563,7 +560,7 @@
                   + newBaseContent
                   + "\n"
                   + ">>>>>>> BASE      ("
-                  + baseSha1
+                  + baseCommit
                   + " "
                   + newBaseCommitMessage
                   + ")\n");
@@ -1584,12 +1581,10 @@
       ByteArrayOutputStream os = new ByteArrayOutputStream();
       bin.writeTo(os);
       String fileContent = new String(os.toByteArray(), UTF_8);
-      String patchSetSha1 = abbreviateName(patchSet, 6);
-      String baseSha1 = abbreviateName(base, 6);
       assertThat(fileContent)
           .isEqualTo(
               "<<<<<<< PATCH SET ("
-                  + patchSetSha1
+                  + patchSet.getName()
                   + " "
                   + patchSetSubject
                   + ")\n"
@@ -1599,7 +1594,7 @@
                   + baseContent
                   + "\n"
                   + ">>>>>>> BASE      ("
-                  + baseSha1
+                  + base.getName()
                   + " "
                   + baseSubject
                   + ")\n");
@@ -2411,12 +2406,10 @@
       ByteArrayOutputStream os = new ByteArrayOutputStream();
       bin.writeTo(os);
       String fileContent = new String(os.toByteArray(), UTF_8);
-      String patchSetSha1 = abbreviateName(parentPatchSet, 6);
-      String baseSha1 = abbreviateName(base, 6);
       assertThat(fileContent)
           .isEqualTo(
               "<<<<<<< PATCH SET ("
-                  + patchSetSha1
+                  + parentPatchSet.getName()
                   + " "
                   + patchSetSubject
                   + ")\n"
@@ -2426,7 +2419,7 @@
                   + baseContent
                   + "\n"
                   + ">>>>>>> BASE      ("
-                  + baseSha1
+                  + base.getName()
                   + " "
                   + baseSubject
                   + ")\n");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
index a29244f..e5604b6 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -413,7 +413,12 @@
             .create();
 
     Change.Id changeId =
-        changeOperations.newChange().author(authorId).committer(committerId).create();
+        changeOperations
+            .newChange()
+            .project(project)
+            .author(authorId)
+            .committer(committerId)
+            .create();
     ChangeInfo changeInfo = gApi.changes().id(changeId.get()).get();
     assertAuthor(changeInfo, "authoremail@example.com");
 
@@ -442,7 +447,12 @@
             .create();
 
     Change.Id changeId =
-        changeOperations.newChange().author(authorId).committer(committerId).create();
+        changeOperations
+            .newChange()
+            .project(project)
+            .author(authorId)
+            .committer(committerId)
+            .create();
     ChangeInfo changeInfo = gApi.changes().id(changeId.get()).get();
     assertCommitter(changeInfo, "committeremail@example.com");
 
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index fb6259b..0dc0604 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -22,7 +22,6 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -397,12 +396,10 @@
     ByteArrayOutputStream os = new ByteArrayOutputStream();
     bin.writeTo(os);
     String fileContent = new String(os.toByteArray(), UTF_8);
-    String destSha1 = abbreviateName(projectOperations.project(project).getHead(destBranch), 6);
-    String changeSha1 = abbreviateName(r.getCommit(), 6);
     assertThat(fileContent)
         .isEqualTo(
             "<<<<<<< HEAD   ("
-                + destSha1
+                + projectOperations.project(project).getHead(destBranch).getName()
                 + " test commit)\n"
                 + destContent
                 + "\n"
@@ -410,7 +407,7 @@
                 + changeContent
                 + "\n"
                 + ">>>>>>> CHANGE ("
-                + changeSha1
+                + r.getCommit().getName()
                 + " test commit)\n");
   }
 
@@ -488,12 +485,10 @@
     ByteArrayOutputStream os = new ByteArrayOutputStream();
     bin.writeTo(os);
     String fileContent = new String(os.toByteArray(), UTF_8);
-    String destSha1 = abbreviateName(existingChange.getCommit(), 6);
-    String changeSha1 = abbreviateName(srcChange.getCommit(), 6);
     assertThat(fileContent)
         .isEqualTo(
             "<<<<<<< HEAD   ("
-                + destSha1
+                + existingChange.getCommit().getName()
                 + " test commit)\n"
                 + destContent
                 + "\n"
@@ -501,7 +496,7 @@
                 + changeContent
                 + "\n"
                 + ">>>>>>> CHANGE ("
-                + changeSha1
+                + srcChange.getCommit().getName()
                 + " test commit)\n");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index 53d9bda..f4a97bd 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -1560,10 +1560,12 @@
   @Test
   public void commentOnParentIsPortedToNewPosition() throws Exception {
     // Set up change and patchsets.
-    Change.Id parentChangeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+    Change.Id parentChangeId =
+        changeOps.newChange().project(project).file("myFile").content("Line 1\n").create();
     Change.Id childChangeId =
         changeOps
             .newChange()
+            .project(project)
             .childOf()
             .change(parentChangeId)
             .file("myFile")
@@ -1592,11 +1594,14 @@
   @Test
   public void commentOnFirstParentIsPortedToNewPosition() throws Exception {
     // Set up change and patchsets.
-    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
-    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id parent1ChangeId =
+        changeOps.newChange().project(project).file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId =
+        changeOps.newChange().project(project).file("file2").content("Line 1\n").create();
     Change.Id childChangeId =
         changeOps
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -1634,11 +1639,14 @@
   @Test
   public void commentOnSecondParentIsPortedToNewPosition() throws Exception {
     // Set up change and patchsets.
-    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
-    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id parent1ChangeId =
+        changeOps.newChange().project(project).file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId =
+        changeOps.newChange().project(project).file("file2").content("Line 1\n").create();
     Change.Id childChangeId =
         changeOps
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -1677,11 +1685,14 @@
   public void commentOnAutoMergeCommitIsPortedToNewPosition() throws Exception {
     // Set up change and patchsets. Use the same file so that there's a meaningful auto-merge
     // commit/diff.
-    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
-    Change.Id parent2ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+    Change.Id parent1ChangeId =
+        changeOps.newChange().project(project).file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId =
+        changeOps.newChange().project(project).file("file1").content("Line 1\n").create();
     Change.Id childChangeId =
         changeOps
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -1729,11 +1740,14 @@
   public void commentOnFirstParentIsPortedToSingleParentWhenPatchsetChangedToNonMergeCommit()
       throws Exception {
     // Set up change and patchsets.
-    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
-    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id parent1ChangeId =
+        changeOps.newChange().project(project).file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId =
+        changeOps.newChange().project(project).file("file2").content("Line 1\n").create();
     Change.Id childChangeId =
         changeOps
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -1770,11 +1784,14 @@
   public void commentOnSecondParentBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
       throws Exception {
     // Set up change and patchsets.
-    Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
-    Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+    Change.Id parent1ChangeId =
+        changeOps.newChange().project(project).file("file1").content("Line 1\n").create();
+    Change.Id parent2ChangeId =
+        changeOps.newChange().project(project).file("file2").content("Line 1\n").create();
     Change.Id childChangeId =
         changeOps
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 7eb33b6..7d82d99 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -3068,6 +3068,7 @@
     Change.Id changeId1 =
         changeOperations
             .newChange()
+            .project(project)
             .file(imageFileName)
             .content(new String(imageBytes, UTF_8))
             .create();
@@ -3076,6 +3077,7 @@
     Change.Id changeId2 =
         changeOperations
             .newChange()
+            .project(project)
             .childOf()
             .change(changeId1)
             .file(imageFileName)
@@ -3106,6 +3108,7 @@
     Change.Id changeId1 =
         changeOperations
             .newChange()
+            .project(project)
             .file(imageFileName)
             .content(new String(imageBytes, UTF_8))
             .create();
@@ -3113,6 +3116,7 @@
     Change.Id changeId2 =
         changeOperations
             .newChange()
+            .project(project)
             .childOf()
             .change(changeId1)
             .file(imageFileName)
@@ -3142,13 +3146,19 @@
     byte[] imageBytes1 = createRgbImage(255, 0, 0);
     String imageContent1 = new String(imageBytes1, UTF_8);
     Change.Id changeId1 =
-        changeOperations.newChange().file(imageFileName1).content(imageContent1).create();
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(imageFileName1)
+            .content(imageContent1)
+            .create();
 
     String imageFileName2 = "another_image.png";
     byte[] imageBytes2 = createRgbImage(0, 255, 0);
     Change.Id changeId2 =
         changeOperations
             .newChange()
+            .project(project)
             .childOf()
             .change(changeId1)
             .file(imageFileName2)
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index de9a040..37e5511 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -31,7 +31,6 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -825,12 +824,10 @@
     ByteArrayOutputStream os = new ByteArrayOutputStream();
     bin.writeTo(os);
     String fileContent = new String(os.toByteArray(), UTF_8);
-    String destSha1 = abbreviateName(projectOperations.project(project).getHead(destBranch), 6);
-    String changeSha1 = abbreviateName(r.getCommit(), 6);
     assertThat(fileContent)
         .isEqualTo(
             "<<<<<<< HEAD   ("
-                + destSha1
+                + projectOperations.project(project).getHead(destBranch).getName()
                 + " test commit)\n"
                 + destContent
                 + "\n"
@@ -838,7 +835,7 @@
                 + changeContent
                 + "\n"
                 + ">>>>>>> CHANGE ("
-                + changeSha1
+                + r.getCommit().getName()
                 + " test commit)\n");
 
     // Get details of cherry-pick change.
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 918916b..fe7857f 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -48,6 +48,7 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.FileMode;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
@@ -77,7 +78,6 @@
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.restapi.change.ChangeEdits;
@@ -89,6 +89,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.Base64;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -109,7 +110,6 @@
   private static final String FILE_NAME2 = "foo2";
   private static final String FILE_NAME3 = "foo3";
   private static final String FILE_NAME4 = "foo4";
-  private static final int FILE_MODE = 100644;
   private static final String CONTENT_OLD_STR = "bar";
   private static final byte[] CONTENT_OLD = CONTENT_OLD_STR.getBytes(UTF_8);
   private static final String CONTENT_NEW_STR = "baz";
@@ -338,11 +338,11 @@
                     + "=======\n"
                     + "%s\n"
                     + ">>>>>>> EDIT      (%s %s)\n",
-                ObjectIds.abbreviateName(currentPatchSet.commitId(), 6),
+                currentPatchSet.commitId().getName(),
                 gApi.changes().id(changeId).get().subject,
                 CONTENT_NEW2_STR,
                 CONTENT_NEW_STR,
-                ObjectIds.abbreviateName(ObjectId.fromString(originalEdit.get().commit.commit), 6),
+                originalEdit.get().commit.commit,
                 originalEdit.get().commit.subject)
             .getBytes(UTF_8));
     assertThat(rebasedEdit).baseRevision().isEqualTo(currentPatchSet.commitId().name());
@@ -383,12 +383,12 @@
                     + "=======\n"
                     + "%s\n"
                     + ">>>>>>> EDIT      (%s %s)\n",
-                ObjectIds.abbreviateName(currentPatchSet.commitId(), 6),
+                currentPatchSet.commitId().getName(),
                 gApi.changes().id(changeId).get().subject,
                 CONTENT_NEW2_STR,
                 CONTENT_OLD_STR,
                 CONTENT_NEW_STR,
-                ObjectIds.abbreviateName(ObjectId.fromString(originalEdit.get().commit.commit), 6),
+                originalEdit.get().commit.commit,
                 originalEdit.get().commit.subject)
             .getBytes(UTF_8));
     assertThat(rebasedEdit).baseRevision().isEqualTo(currentPatchSet.commitId().name());
@@ -1217,21 +1217,148 @@
   }
 
   @Test
-  public void changeEditModifyFileModeRest() throws Exception {
-    createEmptyEditFor(changeId);
+  public void changeEditNewFileIsCreatedAsRegularFileIfFileModeIsNotSet() throws Exception {
+    String fileName = "new.txt";
+
     FileContentInput in = new FileContentInput();
-    in.binary_content = CONTENT_BINARY_ENCODED_NEW;
-    in.fileMode = FILE_MODE;
-    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    in.binaryContent = CONTENT_BINARY_ENCODED_NEW;
+    gApi.changes().id(changeId).edit().modifyFile(fileName, in);
+
+    ensureSameBytes(getFileContentOfEdit(changeId, fileName), CONTENT_BINARY_DECODED_NEW);
+
+    // Publish the change edit so that we can assert the file mode.
+    gApi.changes().id(changeId).edit().publish(new PublishChangeEditInput());
+
+    int mode = gApi.changes().id(changeId).get().getCurrentRevision().files.get(fileName).newMode;
+    assertThat(mode).isEqualTo(FileMode.REGULAR_FILE.getMode());
+  }
+
+  @Test
+  public void changeEditModifyingFileDoesNotUpdateFileModeIfFileModeIsNotSet() throws Exception {
+    FileContentInput in = new FileContentInput();
+    in.binaryContent = CONTENT_BINARY_ENCODED_NEW;
+    in.fileMode = FileMode.EXECUTABLE_FILE.getModeAsOctal();
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, in);
+
     ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW);
+
+    // Update the file content without setting the file mode:
+    in.binaryContent = CONTENT_BINARY_ENCODED_NEW2;
+    in.fileMode = null;
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, in);
+
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW2);
+
+    // Publish the change edit so that we can assert the file mode.
+    gApi.changes().id(changeId).edit().publish(new PublishChangeEditInput());
+
+    int mode = gApi.changes().id(changeId).get().getCurrentRevision().files.get(FILE_NAME).newMode;
+    assertThat(mode).isEqualTo(FileMode.EXECUTABLE_FILE.getMode());
+  }
+
+  @Test
+  public void changeEditModifyFileMode() throws Exception {
+    int mode = gApi.changes().id(changeId).get().getCurrentRevision().files.get(FILE_NAME).newMode;
+    assertThat(mode).isEqualTo(FileMode.REGULAR_FILE.getMode());
+
+    FileContentInput in = new FileContentInput();
+    in.binaryContent = CONTENT_BINARY_ENCODED_NEW;
+    in.fileMode = FileMode.EXECUTABLE_FILE.getModeAsOctal();
+    gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, in);
+
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW);
+
+    // Publish the change edit so that we can assert the new file mode.
+    gApi.changes().id(changeId).edit().publish(new PublishChangeEditInput());
+
+    mode = gApi.changes().id(changeId).get().getCurrentRevision().files.get(FILE_NAME).newMode;
+    assertThat(mode).isEqualTo(FileMode.EXECUTABLE_FILE.getMode());
+  }
+
+  @Test
+  public void changeEditCreateGitLink() throws Exception {
+    String gitlinkFileName = "foo/bar";
+    byte[] gitlinkContent = "7bcbf0ae382f9a20f96563b6af2c0e6fff20a22f".getBytes(UTF_8);
+
+    FileContentInput in = new FileContentInput();
+    in.binaryContent =
+        "data:text/plain;base64," + Base64.getEncoder().encodeToString(gitlinkContent);
+    in.fileMode = FileMode.GITLINK.getModeAsOctal();
+    gApi.changes().id(changeId).edit().modifyFile(gitlinkFileName, in);
+
+    ensureSameBytes(getFileContentOfEdit(changeId, gitlinkFileName), gitlinkContent);
+
+    // Publish the change edit so that we can assert the file mode.
+    gApi.changes().id(changeId).edit().publish(new PublishChangeEditInput());
+
+    int mode =
+        gApi.changes().id(changeId).get().getCurrentRevision().files.get(gitlinkFileName).newMode;
+    assertThat(mode).isEqualTo(FileMode.GITLINK.getMode());
+  }
+
+  @Test
+  public void changeEditCreateSymLink() throws Exception {
+    String symlinkFileName = "foo/bar";
+    byte[] symlinkContent = "path/to/target".getBytes(UTF_8);
+
+    FileContentInput in = new FileContentInput();
+    in.binaryContent =
+        "data:text/plain;base64," + Base64.getEncoder().encodeToString(symlinkContent);
+    in.fileMode = FileMode.SYMLINK.getModeAsOctal();
+    gApi.changes().id(changeId).edit().modifyFile(symlinkFileName, in);
+
+    ensureSameBytes(getFileContentOfEdit(changeId, symlinkFileName), symlinkContent);
+
+    // Publish the change edit so that we can assert the file mode.
+    gApi.changes().id(changeId).edit().publish(new PublishChangeEditInput());
+
+    int mode =
+        gApi.changes().id(changeId).get().getCurrentRevision().files.get(symlinkFileName).newMode;
+    assertThat(mode).isEqualTo(FileMode.SYMLINK.getMode());
+  }
+
+  @Test
+  public void changeEditCreateGitLink_invalidContentIsRejected() throws Exception {
+    String gitlinkFileName = "foo/bar";
+    byte[] invalidGitlinkContent = "not a SHA1".getBytes(UTF_8);
+
+    FileContentInput in = new FileContentInput();
+    in.binaryContent =
+        "data:text/plain;base64," + Base64.getEncoder().encodeToString(invalidGitlinkContent);
+    in.fileMode = FileMode.GITLINK.getModeAsOctal();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).edit().modifyFile(gitlinkFileName, in));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format("The content for gitlink '%s' must be a valid SHA1.", gitlinkFileName));
+  }
+
+  @Test
+  public void changeEditModifyFileMode_invalidFileModeIsRejected() throws Exception {
+    FileContentInput in = new FileContentInput();
+    in.binaryContent = CONTENT_BINARY_ENCODED_NEW;
+    in.fileMode = 16;
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, in));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "file mode %s is invalid: supported values are 100644 (regular file), 100755"
+                    + " (executable file), 120000 (symlink) and 160000 (gitlink)",
+                in.fileMode));
   }
 
   @Test
   public void changeEditModifyFileSetEmptyContentModeRest() throws Exception {
     createEmptyEditFor(changeId);
     FileContentInput in = new FileContentInput();
-    in.binary_content = CONTENT_BINARY_ENCODED_EMPTY;
-    in.fileMode = FILE_MODE;
+    in.binaryContent = CONTENT_BINARY_ENCODED_EMPTY;
     adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
     ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), "".getBytes(UTF_8));
   }
@@ -1239,10 +1366,10 @@
   @Test
   public void createAndUploadBinaryInChangeEditOneRequestRest() throws Exception {
     FileContentInput in = new FileContentInput();
-    in.binary_content = CONTENT_BINARY_ENCODED_NEW;
+    in.binaryContent = CONTENT_BINARY_ENCODED_NEW;
     adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
     ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW);
-    in.binary_content = CONTENT_BINARY_ENCODED_NEW2;
+    in.binaryContent = CONTENT_BINARY_ENCODED_NEW2;
     adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
     ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW2);
   }
@@ -1250,7 +1377,7 @@
   @Test
   public void invalidBase64UploadBinaryInChangeEditOneRequestRest() throws Exception {
     FileContentInput in = new FileContentInput();
-    in.binary_content = CONTENT_BINARY_ENCODED_NEW3;
+    in.binaryContent = CONTENT_BINARY_ENCODED_NEW3;
     adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertBadRequest();
   }
 
@@ -1259,7 +1386,7 @@
     createEmptyEditFor(changeId);
 
     FileContentInput in = new FileContentInput();
-    in.binary_content = null;
+    in.binaryContent = null;
     adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertBadRequest();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index ef6e59d..bc357a8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -28,7 +28,6 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
@@ -806,12 +805,10 @@
     ByteArrayOutputStream os = new ByteArrayOutputStream();
     bin.writeTo(os);
     String fileContent = new String(os.toByteArray(), UTF_8);
-    String sourceSha1 = abbreviateName(projectOperations.project(project).getHead(sourceBranch), 6);
-    String targetSha1 = abbreviateName(projectOperations.project(project).getHead(targetBranch), 6);
     assertThat(fileContent)
         .isEqualTo(
             "<<<<<<< TARGET BRANCH ("
-                + targetSha1
+                + projectOperations.project(project).getHead(targetBranch).getName()
                 + " "
                 + targetSubject
                 + ")\n"
@@ -821,7 +818,7 @@
                 + sourceContent
                 + "\n"
                 + ">>>>>>> SOURCE BRANCH ("
-                + sourceSha1
+                + projectOperations.project(project).getHead(sourceBranch).getName()
                 + " "
                 + sourceSubject
                 + ")\n");
@@ -918,6 +915,64 @@
   }
 
   @Test
+  public void updateCommitMessageOfMergeChangeWithConflicts() throws Exception {
+    // Create a change with a merge commit that contains conflicts
+    String sourceBranch = "source";
+    String targetBranch = "target";
+    ImmutableMap<String, Result> results =
+        changeInTwoBranches(
+            sourceBranch,
+            "source change",
+            "shared.txt",
+            "source content",
+            targetBranch,
+            "target change",
+            "shared.txt",
+            "target content");
+    ChangeInput in =
+        newMergeChangeInput(targetBranch, sourceBranch, "", /* allowConflicts= */ true);
+    in.subject = "Merge " + sourceBranch + " to " + targetBranch;
+    ChangeInfo change = assertCreateSucceedsWithConflicts(in);
+
+    // Verify the conflicts information
+    RevisionInfo currentRevision =
+        gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+    assertThat(currentRevision._number).isEqualTo(1);
+    assertThat(currentRevision.commit.parents.get(0).commit)
+        .isEqualTo(results.get(targetBranch).getCommit().name());
+    assertThat(currentRevision.conflicts).isNotNull();
+    assertThat(currentRevision.conflicts.ours)
+        .isEqualTo(results.get(targetBranch).getCommit().name());
+    assertThat(currentRevision.conflicts.theirs)
+        .isEqualTo(results.get(sourceBranch).getCommit().name());
+    assertThat(currentRevision.conflicts.containsConflicts).isTrue();
+
+    // Update the commit message
+    gApi.changes()
+        .id(change.id)
+        .setMessage(
+            "Merge "
+                + sourceBranch
+                + " branch into "
+                + targetBranch
+                + " branch\n\nChange-Id: "
+                + change.changeId);
+
+    // Verify that the conflicts information has been copied
+    currentRevision =
+        gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+    assertThat(currentRevision._number).isEqualTo(2);
+    assertThat(currentRevision.commit.parents.get(0).commit)
+        .isEqualTo(results.get(targetBranch).getCommit().name());
+    assertThat(currentRevision.conflicts).isNotNull();
+    assertThat(currentRevision.conflicts.ours)
+        .isEqualTo(results.get(targetBranch).getCommit().name());
+    assertThat(currentRevision.conflicts.theirs)
+        .isEqualTo(results.get(sourceBranch).getCommit().name());
+    assertThat(currentRevision.conflicts.containsConflicts).isTrue();
+  }
+
+  @Test
   public void invalidSource() throws Exception {
     changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
     ChangeInput in = newMergeChangeInput("branchA", "invalid", "");
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 8f0624c..4dc64b9 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -576,10 +576,12 @@
 
   @Test
   public void byFileEdits_deletedContent_matching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 2\n", ""))
             .childOf()
@@ -596,10 +598,12 @@
 
   @Test
   public void byFileEdits_deletedContent_nonMatching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 1\n", ""))
             .childOf()
@@ -616,10 +620,12 @@
 
   @Test
   public void byFileEdits_addedContent_matching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT + "line 4\n")
             .childOf()
@@ -636,10 +642,12 @@
 
   @Test
   public void byFileEdits_addedContent_nonMatching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT + "line 4\n")
             .childOf()
@@ -656,10 +664,12 @@
 
   @Test
   public void byFileEdits_addedFile_matching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file("new_file.txt")
             .content("content of the new file")
             .childOf()
@@ -677,10 +687,12 @@
 
   @Test
   public void byFileEdits_addedFile_nonMatching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file("new_file.txt")
             .content("content of the new file")
             .childOf()
@@ -698,10 +710,12 @@
 
   @Test
   public void byFileEdits_modifiedContent_matching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 3\n", "line three\n"))
             .childOf()
@@ -718,10 +732,12 @@
 
   @Test
   public void byFileEdits_modifiedContent_nonMatching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 3\n", "line three\n"))
             .childOf()
@@ -738,10 +754,12 @@
 
   @Test
   public void byFileEdits_modifiedContentPattern_matching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 3\n", "line three\n"))
             .childOf()
@@ -759,10 +777,12 @@
 
   @Test
   public void byFileEdits_exactMatchingWithFilePath_matching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 3\n", "line three\n"))
             .childOf()
@@ -780,10 +800,12 @@
 
   @Test
   public void byFileEdits_exactMatchingWithFilePath_nonMatching() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 3\n", "line three\n"))
             .childOf()
@@ -801,10 +823,12 @@
 
   @Test
   public void byFileEdits_notMatchingWithFilePath() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 3\n", "line three\n"))
             .childOf()
@@ -823,10 +847,12 @@
 
   @Test
   public void byFileEdits_escapeSingleQuotes() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 3\n", "line 'three' is modified\n"))
             .childOf()
@@ -844,10 +870,12 @@
 
   @Test
   public void byFileEdits_doubleEscapeSingleQuote() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             // This will be written to the file as: line \'three\' is modified.
             .content(CONTENT.replace("line 3\n", "line \\'three\\' is modified\n"))
@@ -867,10 +895,12 @@
 
   @Test
   public void byFileEdits_escapeDoubleQuotes() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 3\n", "line \"three\" is modified\n"))
             .childOf()
@@ -888,10 +918,12 @@
 
   @Test
   public void byFileEdits_invalidSyntax() throws Exception {
-    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id parent =
+        changeOperations.newChange().project(project).file(FILE_NAME).content(CONTENT).create();
     Change.Id childId =
         changeOperations
             .newChange()
+            .project(project)
             .file(FILE_NAME)
             .content(CONTENT.replace("line 3\n", "line three\n"))
             .childOf()
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index b240ee2..87fe3db 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -351,9 +351,10 @@
 
   @Test
   public void createdChangeUsesSpecifiedChangeAsParent() throws Exception {
-    Change.Id parentChangeId = changeOperations.newChange().create();
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
 
-    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().change(parentChangeId).create();
 
     ChangeInfo change = getChangeFromServer(changeId);
     CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
@@ -377,11 +378,16 @@
 
   @Test
   public void createdChangeUsesSpecifiedPatchsetAsParent() throws Exception {
-    Change.Id parentChangeId = changeOperations.newChange().create();
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
     TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
 
     Change.Id changeId =
-        changeOperations.newChange().childOf().patchset(parentPatchset.patchsetId()).create();
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .patchset(parentPatchset.patchsetId())
+            .create();
 
     ChangeInfo change = getChangeFromServer(changeId);
     CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
@@ -425,11 +431,12 @@
   @Test
   public void createdChangeUsesSpecifiedCommitAsParent() throws Exception {
     // Currently, the easiest way to create a commit is by creating another change.
-    Change.Id anotherChangeId = changeOperations.newChange().create();
+    Change.Id anotherChangeId = changeOperations.newChange().project(project).create();
     ObjectId parentCommitId =
         changeOperations.change(anotherChangeId).currentPatchset().get().commitId();
 
-    Change.Id changeId = changeOperations.newChange().childOf().commit(parentCommitId).create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().commit(parentCommitId).create();
 
     ChangeInfo change = getChangeFromServer(changeId);
     CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
@@ -456,12 +463,13 @@
 
   @Test
   public void createdChangeUsesSpecifiedChangesInGivenOrderAsParents() throws Exception {
-    Change.Id parent1ChangeId = changeOperations.newChange().create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
 
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -484,13 +492,19 @@
   @Test
   public void createdChangeUsesMergedParentsAsBaseCommit() throws Exception {
     Change.Id parent1ChangeId =
-        changeOperations.newChange().file("file1").content("Line 1").create();
+        changeOperations.newChange().project(project).file("file1").content("Line 1").create();
     Change.Id parent2ChangeId =
-        changeOperations.newChange().file("file2").content("Some other content").create();
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file2")
+            .content("Some other content")
+            .create();
 
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -507,9 +521,9 @@
   @Test
   public void mergeConflictsOfParentsAreReported() {
     Change.Id parent1ChangeId =
-        changeOperations.newChange().file("file1").content("Content 1").create();
+        changeOperations.newChange().project(project).file("file1").content("Content 1").create();
     Change.Id parent2ChangeId =
-        changeOperations.newChange().file("file1").content("Content 2").create();
+        changeOperations.newChange().project(project).file("file1").content("Content 2").create();
 
     IllegalStateException exception =
         assertThrows(
@@ -517,6 +531,7 @@
             () ->
                 changeOperations
                     .newChange()
+                    .project(project)
                     .mergeOf()
                     .change(parent1ChangeId)
                     .and()
@@ -529,13 +544,14 @@
   @Test
   public void mergeConflictsCanBeAvoidedByUsingTheFirstParentAsBase() throws Exception {
     Change.Id parent1ChangeId =
-        changeOperations.newChange().file("file1").content("Content 1").create();
+        changeOperations.newChange().project(project).file("file1").content("Content 1").create();
     Change.Id parent2ChangeId =
-        changeOperations.newChange().file("file1").content("Content 2").create();
+        changeOperations.newChange().project(project).file("file1").content("Content 2").create();
 
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOfButBaseOnFirst()
             .change(parent1ChangeId)
             .and()
@@ -549,12 +565,13 @@
 
   @Test
   public void createdChangeHasAllParentsEvenWhenBasedOnFirst() throws Exception {
-    Change.Id parent1ChangeId = changeOperations.newChange().create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
 
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOfButBaseOnFirst()
             .change(parent1ChangeId)
             .and()
@@ -603,15 +620,16 @@
   @Test
   public void createdChangeCanHaveMoreThanTwoParentsWhenBasedOnFirst() throws Exception {
     Change.Id parent1ChangeId =
-        changeOperations.newChange().file("file1").content("Content 1").create();
+        changeOperations.newChange().project(project).file("file1").content("Content 1").create();
     Change.Id parent2ChangeId =
-        changeOperations.newChange().file("file2").content("Content 2").create();
+        changeOperations.newChange().project(project).file("file2").content("Content 2").create();
     Change.Id parent3ChangeId =
-        changeOperations.newChange().file("file3").content("Content 3").create();
+        changeOperations.newChange().project(project).file("file3").content("Content 3").create();
 
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOfButBaseOnFirst()
             .change(parent1ChangeId)
             .followedBy()
@@ -640,6 +658,7 @@
     Change.Id parentChangeId =
         changeOperations
             .newChange()
+            .project(project)
             .file("file1")
             .content("Content 1")
             .file("file2")
@@ -649,6 +668,7 @@
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .childOf()
             .change(parentChangeId)
             .file("file1")
@@ -665,13 +685,14 @@
   @Test
   public void changeFromMergedParentsMayHaveAdditionalFileModifications() throws Exception {
     Change.Id parent1ChangeId =
-        changeOperations.newChange().file("file1").content("Content 1").create();
+        changeOperations.newChange().project(project).file("file1").content("Content 1").create();
     Change.Id parent2ChangeId =
-        changeOperations.newChange().file("file2").content("Content 2").create();
+        changeOperations.newChange().project(project).file("file2").content("Content 2").create();
 
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -691,12 +712,13 @@
   public void changeBasedOnFirstOfMultipleParentsMayHaveAdditionalFileModifications()
       throws Exception {
     Change.Id parent1ChangeId =
-        changeOperations.newChange().file("file1").content("Content 1").create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
+        changeOperations.newChange().project(project).file("file1").content("Content 1").create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
 
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOfButBaseOnFirst()
             .change(parent1ChangeId)
             .and()
@@ -1605,14 +1627,24 @@
 
   @Test
   public void newPatchsetCanHaveADifferentParent() throws Exception {
-    Change.Id originalParentChange = changeOperations.newChange().create();
-    Change.Id changeId =
-        changeOperations.newChange().childOf().change(originalParentChange).create();
-    Change.Id newParentChange = changeOperations.newChange().create();
+    Change.Id originalParentChange = changeOperations.newChange().project(project).create();
+    Change.Id changeIdentifier =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(originalParentChange)
+            .create();
+    Change.Id newParentChange = changeOperations.newChange().project(project).create();
 
-    changeOperations.change(changeId).newPatchset().parent().change(newParentChange).create();
+    changeOperations
+        .change(changeIdentifier)
+        .newPatchset()
+        .parent()
+        .change(newParentChange)
+        .create();
 
-    ChangeInfo change = getChangeFromServer(changeId);
+    ChangeInfo change = getChangeFromServer(changeIdentifier);
     CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
     ObjectId newParentCommitId =
         changeOperations.change(newParentChange).currentPatchset().get().commitId();
@@ -1625,18 +1657,19 @@
 
   @Test
   public void newPatchsetCanHaveDifferentParents() throws Exception {
-    Change.Id originalParent1Change = changeOperations.newChange().create();
-    Change.Id originalParent2Change = changeOperations.newChange().create();
+    Change.Id originalParent1Change = changeOperations.newChange().project(project).create();
+    Change.Id originalParent2Change = changeOperations.newChange().project(project).create();
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(originalParent1Change)
             .and()
             .change(originalParent2Change)
             .create();
-    Change.Id newParent1Change = changeOperations.newChange().create();
-    Change.Id newParent2Change = changeOperations.newChange().create();
+    Change.Id newParent1Change = changeOperations.newChange().project(project).create();
+    Change.Id newParent2Change = changeOperations.newChange().project(project).create();
 
     changeOperations
         .change(changeId)
@@ -1661,11 +1694,16 @@
 
   @Test
   public void newPatchsetCanHaveADifferentNumberOfParents() throws Exception {
-    Change.Id originalParentChange = changeOperations.newChange().create();
+    Change.Id originalParentChange = changeOperations.newChange().project(project).create();
     Change.Id changeId =
-        changeOperations.newChange().childOf().change(originalParentChange).create();
-    Change.Id newParent1Change = changeOperations.newChange().create();
-    Change.Id newParent2Change = changeOperations.newChange().create();
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(originalParentChange)
+            .create();
+    Change.Id newParent1Change = changeOperations.newChange().project(project).create();
+    Change.Id newParent2Change = changeOperations.newChange().project(project).create();
 
     changeOperations
         .change(changeId)
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
index be71e6e..2cc91dd 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
@@ -210,11 +210,12 @@
 
   @Test
   public void commentCanBeCreatedOnSecondParentCommit() throws Exception {
-    Change.Id parent1ChangeId = changeOperations.newChange().create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -236,8 +237,9 @@
 
   @Test
   public void commentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
-    Change.Id parentChangeId = changeOperations.newChange().create();
-    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().change(parentChangeId).create();
 
     String commentUuid =
         changeOperations
@@ -257,11 +259,12 @@
 
   @Test
   public void commentCanBeCreatedOnAutoMergeCommit() throws Exception {
-    Change.Id parent1ChangeId = changeOperations.newChange().create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -578,11 +581,12 @@
 
   @Test
   public void draftCommentCanBeCreatedOnSecondParentCommit() throws Exception {
-    Change.Id parent1ChangeId = changeOperations.newChange().create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
@@ -604,8 +608,9 @@
 
   @Test
   public void draftCommentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
-    Change.Id parentChangeId = changeOperations.newChange().create();
-    Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+    Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+    Change.Id changeId =
+        changeOperations.newChange().project(project).childOf().change(parentChangeId).create();
 
     String commentUuid =
         changeOperations
@@ -625,11 +630,12 @@
 
   @Test
   public void draftCommentCanBeCreatedOnAutoMergeCommit() throws Exception {
-    Change.Id parent1ChangeId = changeOperations.newChange().create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
diff --git a/plugins/replication b/plugins/replication
index 38a79f7..721b87a 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 38a79f76b4432679b69736dd7cea4ed254a6f09d
+Subproject commit 721b87a89e3a1bd2c368e051dec12445697680c5
diff --git a/polygerrit-ui/app/api/change-updates.ts b/polygerrit-ui/app/api/change-updates.ts
new file mode 100644
index 0000000..6ed9a8d
--- /dev/null
+++ b/polygerrit-ui/app/api/change-updates.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export declare interface ChangeUpdatesPluginApi {
+  /**
+   * Must only be called once. You cannot register twice. You cannot unregister.
+   */
+  register(publisher: ChangeUpdatesPublisher): void;
+}
+
+/**
+ * Publisher that notifies subscribers whenever the change updates.
+ *
+ * This update could be change being submitted, new patchset being uploaded etc.
+ * Plugins are expected to use the ChangeUpdatesPluginApi to register a publisher.
+ * Gerrit will use the "subscribe" method to register a callback to this publisher.
+ * One use case is updating the ChangeModel state whenever the change is updated.
+ */
+export declare interface ChangeUpdatesPublisher {
+  /**
+   * Subcribers can use this method to add a callback that will be triggered when updates to the change happen.
+   *
+   * @param repo Repository containing the change.
+   * @param change The change number of the change for which update events are published.
+   * @param callback The callback to be called when the change is updated.
+   */
+  subscribe(repo: string, change: number, callback: () => void): void;
+  /**
+   * Remove existing callbacks. Does nothing if no subscriber was added.
+   */
+  unsubscribe(): void;
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 8110d71..ed85fe2 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -17,6 +17,7 @@
 import {HookApi, RegisterOptions} from './hook';
 import {StylePluginApi} from './styles';
 import {SuggestionsPluginApi} from './suggestions';
+import {ChangeUpdatesPluginApi} from './change-updates';
 
 export enum TargetElement {
   CHANGE_ACTIONS = 'changeactions',
@@ -66,6 +67,7 @@
   attributeHelper(element: Element): AttributeHelperPluginApi;
   changeActions(): ChangeActionsPluginApi;
   changeReply(): ChangeReplyPluginApi;
+  changeUpdates(): ChangeUpdatesPluginApi;
   checks(): ChecksPluginApi;
   suggestions(): SuggestionsPluginApi;
   eventHelper(element: Node): EventHelperPluginApi;
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 962e765..d7a132f 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -327,14 +327,13 @@
 }
 
 /**
- * The AvartarInfo entity contains information about an avatar image ofan
+ * The AvatarInfo entity contains information about an avatar image of an
  * account.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#avatar-info
  */
 export declare interface AvatarInfo {
   url: string;
   height: number;
-  width: number;
 }
 
 // The refs/heads/ prefix is omitted in Branch name
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 589340f..9db6ab6 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -61,7 +61,7 @@
   STARTUP_DIFF_VIEW_DISPLAYED = 'StartupDiffViewDisplayed',
   // Time from startup to showing initial content of the file list.
   STARTUP_FILE_LIST_DISPLAYED = 'StartupFileListDisplayed',
-  // Time from startup to when the webcomponentsready event is fired. If the event is fired from the webcomponents-lite polyfill, this may be arbitrarily long after the app has started.
+  // Time from startup to when the webcomponentsready event is fired. If the event is fired from the webcomponents-bundle polyfill, this may be arbitrarily long after the app has started.
   WEB_COMPONENTS_READY = 'WebComponentsReady',
   // Time to received all data for change view
   CHANGE_DATA = 'ChangeDataLoaded',
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 41172ca..b32a6f5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -159,15 +159,6 @@
 const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
 
 const PREFIX = '#message-';
-
-const ReloadToastMessage = {
-  NEWER_REVISION: 'A newer patch set has been uploaded',
-  RESTORED: 'This change has been restored',
-  ABANDONED: 'This change has been abandoned',
-  MERGED: 'This change has been merged',
-  NEW_MESSAGE: 'There are new messages on this change',
-};
-
 @customElement('gr-change-view')
 export class GrChangeView extends LitElement {
   /**
@@ -2191,51 +2182,7 @@
         this.startUpdateCheckTimer();
         return;
       }
-      const change = this.change;
-      this.getChangeModel()
-        .fetchChangeUpdates(change, /* includeExtraOptions = */ true)
-        .then(result => {
-          let toastMessage = null;
-          if (!result.isLatest) {
-            toastMessage = ReloadToastMessage.NEWER_REVISION;
-          } else if (result.newStatus === ChangeStatus.MERGED) {
-            toastMessage = ReloadToastMessage.MERGED;
-          } else if (result.newStatus === ChangeStatus.ABANDONED) {
-            toastMessage = ReloadToastMessage.ABANDONED;
-          } else if (result.newStatus === ChangeStatus.NEW) {
-            toastMessage = ReloadToastMessage.RESTORED;
-          } else if (result.newMessages) {
-            toastMessage = ReloadToastMessage.NEW_MESSAGE;
-            if (result.newMessages.author?.name) {
-              toastMessage += ` from ${result.newMessages.author.name}`;
-            }
-          }
-
-          // We have to make sure that the update is still relevant for the user.
-          // Since starting to fetch the change update the user may have sent a
-          // reply, or the change might have been reloaded, or it could be in the
-          // process of being reloaded.
-          const changeWasReloaded = change !== this.change;
-          if (
-            !toastMessage ||
-            this.loading ||
-            changeWasReloaded ||
-            !this.isViewCurrent
-          ) {
-            this.startUpdateCheckTimer();
-            return;
-          }
-
-          this.cancelUpdateCheckTimer();
-          fire(this, 'show-alert', {
-            message: toastMessage,
-            // Persist this alert.
-            dismissOnNavigation: true,
-            showDismiss: true,
-            action: 'Reload',
-            callback: () => this.getChangeModel().navigateToChangeResetReload(),
-          });
-        });
+      this.getChangeModel().throttledShowUpdateChangeNotification();
     }, delay * 1000);
   }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-updates-api/gr-change-updates-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-updates-api/gr-change-updates-api.ts
new file mode 100644
index 0000000..ebd2f25
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-change-updates-api/gr-change-updates-api.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PluginApi} from '../../../api/plugin';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {
+  ChangeUpdatesPluginApi,
+  ChangeUpdatesPublisher,
+} from '../../../api/change-updates';
+
+enum State {
+  NOT_REGISTERED,
+  REGISTERED,
+}
+
+/**
+ * Plugin API for change updates.
+ *
+ * Instance of this type created and returned to plugins that want to provide change updates data.
+ * Plugins normally just call register() once at startup.
+ */
+export class GrChangeUpdatesApi implements ChangeUpdatesPluginApi {
+  private state = State.NOT_REGISTERED;
+
+  constructor(
+    private readonly pluginsModel: PluginsModel,
+    readonly plugin: PluginApi
+  ) {}
+
+  register(publisher: ChangeUpdatesPublisher): void {
+    if (this.state === State.REGISTERED) {
+      throw new Error('Only one publisher can be registered per plugin.');
+    }
+    this.state = State.REGISTERED;
+    this.pluginsModel.changeUpdatesRegister({
+      pluginName: this.plugin.getPluginName(),
+      publisher,
+    });
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
index 47026b4..dc162cf 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
@@ -33,7 +33,6 @@
             {
               url: `https://a.b.c/photo${i}.jpg`,
               height: 32,
-              width: 32,
             },
           ],
         });
@@ -45,7 +44,6 @@
             // Account with duplicate avatar will be skipped.
             url: 'https://a.b.c/photo1.jpg',
             height: 32,
-            width: 32,
           },
         ],
       });
@@ -92,7 +90,6 @@
             {
               url: `https://a.b.c/photo${i}.jpg`,
               height: 32,
-              width: 32,
             },
           ],
         });
@@ -104,7 +101,6 @@
             // Account with duplicate avatar will be skipped.
             url: 'https://a.b.c/photo1.jpg',
             height: 32,
-            width: 32,
           },
         ],
       });
@@ -152,7 +148,6 @@
           {
             url: 'https://a.b.c/photo0.jpg',
             height: 32,
-            width: 32,
           },
         ],
         registered_on: '1234' as Timestamp,
@@ -217,7 +212,6 @@
             {
               url: `https://a.b.c/photo${i}.jpg`,
               height: 32,
-              width: 32,
             },
           ],
         });
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
index 2b9cacd..4a2175d 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
@@ -21,7 +21,6 @@
     {
       url: 'https://cdn.example.com/s12-p/photo.jpg',
       height: 12,
-      width: 0,
     },
   ];
 
@@ -149,17 +148,14 @@
           {
             url: 'https://cdn.example.com/s12-p/photo.jpg',
             height: 12,
-            width: 0,
           },
           {
             url: 'https://cdn.example.com/s16-p/photo.jpg',
             height: 16,
-            width: 0,
           },
           {
             url: 'https://cdn.example.com/s100-p/photo.jpg',
             height: 100,
-            width: 0,
           },
         ],
       };
@@ -181,7 +177,6 @@
           {
             url: 'https://cdn.example.com/s95-p/photo.jpg',
             height: 95,
-            width: 0,
           },
         ],
       };
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index b076373..35c93e67 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -36,6 +36,7 @@
 import {GrPluginStyleApi} from './gr-plugin-style-api';
 import {StylePluginApi} from '../../../api/styles';
 import {GrSuggestionsApi} from '../../plugins/gr-suggestions-api/gr-suggestions-api';
+import {GrChangeUpdatesApi} from '../../plugins/gr-change-updates-api/gr-change-updates-api';
 
 const PLUGIN_NAME_NOT_SET = 'NULL';
 
@@ -204,6 +205,10 @@
     return new GrChecksApi(this.report, this.pluginsModel, this);
   }
 
+  changeUpdates(): GrChangeUpdatesApi {
+    return new GrChangeUpdatesApi(this.pluginsModel, this);
+  }
+
   suggestions(): GrSuggestionsApi {
     return new GrSuggestionsApi(this.report, this.pluginsModel, this);
   }
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 113465b..df09209 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -42,7 +42,7 @@
   LoadingStatus,
   ParsedChangeInfo,
 } from '../../types/types';
-import {fireAlert, fireTitleChange} from '../../utils/event-util';
+import {fire, fireAlert, fireTitleChange} from '../../utils/event-util';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {select} from '../../utils/observable-util';
 import {assertIsDefined} from '../../utils/common-util';
@@ -66,9 +66,18 @@
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {Timing} from '../../constants/reporting';
 import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {throttleWrap} from '../../utils/async-util';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 
+const ReloadToastMessage = {
+  NEWER_REVISION: 'A newer patch set has been uploaded',
+  RESTORED: 'This change has been restored',
+  ABANDONED: 'This change has been abandoned',
+  MERGED: 'This change has been merged',
+  NEW_MESSAGE: 'There are new messages on this change',
+};
+
 export interface ChangeState {
   /**
    * If `change` is undefined, this must be either NOT_LOADED or LOADING.
@@ -449,6 +458,10 @@
 
   public readonly revertingChangeIds$;
 
+  public throttledShowUpdateChangeNotification: () => void;
+
+  private isViewCurrent: boolean = false;
+
   constructor(
     private readonly navigation: NavigationService,
     private readonly viewModel: ChangeViewModel,
@@ -511,6 +524,19 @@
     this.latestRevisionWithEdit$ = this.selectRevision(
       this.latestPatchNumWithEdit$
     );
+    this.change$.subscribe(change => {
+      const changeUpdatesPlugins =
+        this.pluginLoader.pluginsModel.getChangeUpdatesPlugins();
+      for (const plugin of changeUpdatesPlugins) {
+        plugin.publisher.unsubscribe();
+      }
+      if (!change) return;
+      for (const plugin of changeUpdatesPlugins) {
+        plugin.publisher.subscribe(change.project, change._number, () =>
+          this.throttledShowUpdateChangeNotification()
+        );
+      }
+    });
     this.isOwner$ = select(
       combineLatest([this.change$, this.userModel.account$]),
       ([change, account]) => isOwner(change, account)
@@ -542,7 +568,14 @@
       this.latestPatchNum$.subscribe(
         latestPatchNum => (this.latestPatchNum = latestPatchNum)
       ),
+      this.viewModel.childView$.subscribe(childView => {
+        this.isViewCurrent = childView === ChangeChildView.OVERVIEW;
+      }),
     ];
+    this.throttledShowUpdateChangeNotification = throttleWrap(
+      () => this.showRefreshChangeNotification(),
+      60 * 1000
+    );
   }
 
   private reportChangeReload() {
@@ -856,6 +889,49 @@
     this.navigation.setUrl(url);
   }
 
+  // private but used in tests
+  async showRefreshChangeNotification() {
+    const change = this.change;
+    if (!change) return;
+    const toastMessage = await this.getChangeUpdateToastMessage(change);
+    if (!toastMessage) return;
+    // We have to make sure that the update is still relevant for the user.
+    // Since starting to fetch the change update the user may have sent a
+    // reply, or the change might have been reloaded, or it could be in the
+    // process of being reloaded.
+    if (change !== this.change || !this.isViewCurrent) {
+      return;
+    }
+    fire(document, 'show-alert', {
+      message: toastMessage,
+      // Persist this alert.
+      dismissOnNavigation: true,
+      showDismiss: true,
+      action: 'Reload',
+      callback: () => this.navigateToChangeResetReload(),
+    });
+  }
+
+  async getChangeUpdateToastMessage(change: ParsedChangeInfo) {
+    const result = await this.fetchChangeUpdates(change);
+    let toastMessage = null;
+    if (!result.isLatest) {
+      toastMessage = ReloadToastMessage.NEWER_REVISION;
+    } else if (result.newStatus === ChangeStatus.MERGED) {
+      toastMessage = ReloadToastMessage.MERGED;
+    } else if (result.newStatus === ChangeStatus.ABANDONED) {
+      toastMessage = ReloadToastMessage.ABANDONED;
+    } else if (result.newStatus === ChangeStatus.NEW) {
+      toastMessage = ReloadToastMessage.RESTORED;
+    } else if (result.newMessages) {
+      toastMessage = ReloadToastMessage.NEW_MESSAGE;
+      if (result.newMessages.author?.name) {
+        toastMessage += ` from ${result.newMessages.author.name}`;
+      }
+    }
+    return toastMessage;
+  }
+
   /**
    * Check whether there are new updates on the change.
    *
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index d3de4695..2fc8f8a 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -21,6 +21,7 @@
 import {
   mockPromise,
   stubRestApi,
+  waitUntil,
   waitUntilObserved,
 } from '../../test/test-utils';
 import {
@@ -353,6 +354,48 @@
     });
   });
 
+  test('subscribes to change updates provider and shows notification to refetch the change', async () => {
+    await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+    const pluginLoader = testResolver(pluginLoaderToken);
+    const unsubscribeStub = sinon.stub();
+    const callbacks: (() => void)[] = [];
+    let subscribeCallCount = 0;
+    const subscribeStub = (
+      _repo: string,
+      _changeNum: number,
+      callback: () => void
+    ) => {
+      subscribeCallCount++;
+      callbacks.push(callback);
+    };
+    stubRestApi('getChangeDetail').returns(
+      Promise.resolve(createParsedChange())
+    );
+    sinon.stub(pluginLoader.pluginsModel, 'getChangeUpdatesPlugins').returns([
+      {
+        pluginName: 'plugin',
+        publisher: {
+          unsubscribe: unsubscribeStub,
+          subscribe: subscribeStub,
+        },
+      },
+    ]);
+
+    changeModel.updateStateChange(createParsedChange());
+    assert.isTrue(unsubscribeStub.calledOnce);
+    assert.equal(subscribeCallCount, 1);
+
+    const showRefreshChangeNotificationStub = sinon.stub(
+      changeModel,
+      'showRefreshChangeNotification'
+    );
+
+    for (const callback of callbacks) {
+      callback();
+    }
+    await waitUntil(() => showRefreshChangeNotificationStub.callCount === 1);
+  });
+
   test('fireShowChange from overview', async () => {
     await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
     const pluginLoader = testResolver(pluginLoaderToken);
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 880bcd0..9df61bc 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -14,6 +14,7 @@
 import {select} from '../../utils/observable-util';
 import {CoverageProvider, TokenHoverListener} from '../../api/annotation';
 import {SuggestionsProvider} from '../../api/suggestions';
+import {ChangeUpdatesPublisher} from '../../api/change-updates';
 
 export interface CoveragePlugin {
   pluginName: string;
@@ -26,6 +27,11 @@
   config: ChecksApiConfig;
 }
 
+export interface ChangeUpdatesPlugin {
+  pluginName: string;
+  publisher: ChangeUpdatesPublisher;
+}
+
 export interface SuggestionPlugin {
   pluginName: string;
   provider: SuggestionsProvider;
@@ -54,6 +60,11 @@
    */
   coveragePlugins: CoveragePlugin[];
   /**
+   * List of plugins that have registered a publisher for change updated events.
+   */
+  changeUpdatesPlugins: ChangeUpdatesPlugin[];
+
+  /**
    * List of plugins that have called checks().register().
    */
   checksPlugins: ChecksPlugin[];
@@ -89,6 +100,11 @@
 
   public coveragePlugins$ = select(this.state$, state => state.coveragePlugins);
 
+  public changeUpdatesPlugins$ = select(
+    this.state$,
+    state => state.changeUpdatesPlugins
+  );
+
   public suggestionsPlugins$ = select(
     this.state$,
     state => state.suggestionsPlugins
@@ -100,6 +116,7 @@
     super({
       pluginsLoaded: false,
       coveragePlugins: [],
+      changeUpdatesPlugins: [],
       checksPlugins: [],
       suggestionsPlugins: [],
       tokenHighlightPlugins: [],
@@ -122,6 +139,26 @@
     this.setState(nextState);
   }
 
+  getChangeUpdatesPlugins() {
+    return this.getState().changeUpdatesPlugins;
+  }
+
+  changeUpdatesRegister(plugin: ChangeUpdatesPlugin) {
+    const nextState = {...this.getState()};
+    nextState.changeUpdatesPlugins = [...nextState.changeUpdatesPlugins];
+    const alreadyRegistered = nextState.changeUpdatesPlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a change updates provider. Ignored.`
+      );
+      return;
+    }
+    nextState.changeUpdatesPlugins.push(plugin);
+    this.setState(nextState);
+  }
+
   checksRegister(plugin: ChecksPlugin) {
     const nextState = {...this.getState()};
     nextState.checksPlugins = [...nextState.checksPlugins];
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 397aa9a1..b40a8b1 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -22,7 +22,7 @@
     "@types/resemblejs": "^4.1.3",
     "@types/resize-observer-browser": "^0.1.11",
     "@webcomponents/shadycss": "^1.11.2",
-    "@webcomponents/webcomponentsjs": "^1.3.3",
+    "@webcomponents/webcomponentsjs": "^2.8.0",
     "highlight.js": "^11.11.1",
     "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba",
     "highlightjs-epp": "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9",
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 2c29cc7..593c898 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -111,8 +111,8 @@
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
             "//lib/js:emojis__files",
-            "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js",
-            "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js.map",
+            "@ui_npm//@webcomponents/webcomponentsjs",
+            "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/package.json",
             "@ui_npm//:node_modules/resemblejs/resemble.js",
             "@ui_npm//@polymer/font-roboto-local",
             "@ui_npm//:node_modules/@polymer/font-roboto-local/package.json",
@@ -120,7 +120,8 @@
         outs = outs,
         cmd = " && ".join([
             "FONT_DIR=$$(dirname $(location @ui_npm//:node_modules/@polymer/font-roboto-local/package.json))/fonts",
-            "mkdir -p $$TMP/polygerrit_ui/{workers,styles/themes,fonts/{roboto,robotomono},bower_components/{emojis,highlightjs,webcomponentsjs,resemblejs},elements}",
+            "WEBCOMPONENTJS_DIR=$$(dirname $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/package.json))",
+            "mkdir -p $$TMP/polygerrit_ui/{workers,styles/themes,fonts/{roboto,robotomono},bower_components/{emojis,highlightjs,webcomponentsjs,webcomponentsjs/bundles,resemblejs},elements}",
             "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + app_name + ".$$ext; done",
             "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
             "cp $(locations //lib/fonts:material-icons) $$TMP/polygerrit_ui/fonts/",
@@ -129,8 +130,8 @@
             "for f in $(locations " + name + "_worker_sources); do cp $$f $$TMP/polygerrit_ui/workers; done",
             "for f in $(locations //lib/js:highlightjs__files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
             "for f in $(locations //lib/js:emojis__files); do cp $$f $$TMP/polygerrit_ui/bower_components/emojis/ ; done",
-            "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js",
-            "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js.map) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js.map",
+            "cp $$WEBCOMPONENTJS_DIR/webcomponents-loader.js $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-loader.js",
+            "cp $$WEBCOMPONENTJS_DIR/bundles/* $$TMP/polygerrit_ui/bower_components/webcomponentsjs/bundles/",
             "cp $(location @ui_npm//:node_modules/resemblejs/resemble.js) $$TMP/polygerrit_ui/bower_components/resemblejs/resemble.js",
             "cp $$FONT_DIR/roboto/*.ttf $$TMP/polygerrit_ui/fonts/roboto/",
             "cp $$FONT_DIR/robotomono/*.ttf $$TMP/polygerrit_ui/fonts/robotomono/",
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 32a6867..599ecc1 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -229,19 +229,15 @@
   if (existingPromise) existingPromise.delegate(promise);
   return promise;
 }
-const THROTTLE_INTERVAL_MS = 500;
 
 /**
  * Ensure only one call is made within THROTTLE_INTERVAL_MS and any call within
  * this interval is ignored
  */
-export function throttleWrap<T>(fn: (e: T) => void) {
+export function throttleWrap<T>(fn: (e?: T) => void, throttleInterval = 500) {
   let lastCall: number | undefined;
-  return (e: T) => {
-    if (
-      lastCall !== undefined &&
-      Date.now() - lastCall < THROTTLE_INTERVAL_MS
-    ) {
+  return (e?: T) => {
+    if (lastCall !== undefined && Date.now() - lastCall < throttleInterval) {
       return;
     }
     lastCall = Date.now();
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 418e873..2dab265 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -212,8 +212,6 @@
     "@polymer/paper-behaviors" "^3.0.0-pre.27"
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
-    "@polymer/paper-styles" "^3.0.0-pre.26"
-    "@polymer/polymer" "^3.0.0"
 
 "@polymer/paper-input@^3.2.1":
   version "3.2.1"
@@ -300,12 +298,7 @@
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.2.tgz#7539b0ad29598aa2eafee8b341059e20ac9e1006"
   integrity sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==
 
-"@webcomponents/webcomponentsjs@^1.3.3":
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
-  integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
-
-"@webcomponents/webcomponentsjs@^2.0.3":
+"@webcomponents/webcomponentsjs@^2.0.3", "@webcomponents/webcomponentsjs@^2.8.0":
   version "2.8.0"
   resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz#ab21f027594fa827c1889e8b646da7be27c7908a"
   integrity sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w==
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 86970f8..b9b60c2 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -141,9 +141,9 @@
   {/if}
   <link rel="preload" as="style" href="{$staticResourcePath}/styles/main.css">{\n}
 
-  <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
+  <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-loader.js"></script>{\n}
 
-  // Content between webcomponents-lite and the load of the main app element
+  // Content between webcomponents-loader and the load of the main app element
   // run before polymer-resin is installed so may have security consequences.
   // Contact your local security engineer if you have any questions, and
   // CC them on any changes that load content before gr-app.js.
diff --git a/web-dev-server.config.mjs b/web-dev-server.config.mjs
index 53a240e..8820ecd 100644
--- a/web-dev-server.config.mjs
+++ b/web-dev-server.config.mjs
@@ -25,9 +25,14 @@
     // polygerrit-ui/app/rules.bzl
     async (context, next) => {
 
-      if ( context.url.includes("/bower_components/webcomponentsjs/webcomponents-lite.js") ) {
-        context.response.redirect("/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js");
-
+      if ( context.url.includes("/bower_components/webcomponentsjs/webcomponents-loader.js") ) {
+        context.response.redirect("/node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js");
+      } else if (context.url.includes("/bower_components/webcomponentsjs/bundles/")) {
+        const bundlePath = context.url.replace(
+          "/bower_components/webcomponentsjs/bundles/",
+          "/node_modules/@webcomponents/webcomponentsjs/bundles/"
+        );
+        context.response.redirect(bundlePath);
       } else if ( context.url.startsWith( "/fonts/" ) ) {
         const fontFile = path.join( "lib/fonts", path.basename(context.url) );
         context.body = fs.createReadStream( fontFile );