Merge "gr-main-header: Fix hamburger colour when opened"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 50885b6..dd2c3de 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -7224,12 +7224,10 @@
 Number of inserted lines.
 |`deletions`          ||
 Number of deleted lines.
-|`total_comment_count`  |optional|
-Total number of inline comments across all patch sets. Not set if the current
-change index doesn't have the data.
-|`unresolved_comment_count`  |optional|
-Number of unresolved inline comment threads across all patch sets. Not set if
-the current change index doesn't have the data.
+|`total_comment_count`  ||
+Total number of inline comments across all patch sets.
+|`unresolved_comment_count`  ||
+Number of unresolved inline comment threads across all patch sets.
 |`_number`            ||
 The change number. (The underscore is just a relict of a prior
 attempt to deprecate the change number.)
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 96a6d32..cd805d0 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1550,6 +1550,17 @@
     assertThat(res).isEqualTo(expectedContent);
   }
 
+  protected void assertLastCommitAuthorAndShortMessage(
+      String refName, String expectedAuthor, String expectedShortMessage) throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref exactRef = repo.exactRef(refName);
+      RevCommit revCommit = rw.parseCommit(exactRef.getObjectId());
+      assertThat(revCommit.getAuthorIdent().getName()).isEqualTo(expectedAuthor);
+      assertThat(revCommit.getShortMessage()).isEqualTo(expectedShortMessage);
+    }
+  }
+
   @CanIgnoreReturnValue
   protected RevCommit createNewCommitWithoutChangeId(String branch, String file, String content)
       throws Exception {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 3e8002b..3a50275 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.AccessSection;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
@@ -27,9 +25,10 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -39,23 +38,18 @@
 @Singleton
 public class CreateAccessChange implements RestModifyView<ProjectResource, ProjectAccessInput> {
   private final SetAccessUtil setAccess;
-  private final ChangeJson.Factory jsonFactory;
   private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  CreateAccessChange(
-      SetAccessUtil accessUtil,
-      ChangeJson.Factory jsonFactory,
-      RepoMetaDataUpdater repoMetaDataUpdater) {
+  CreateAccessChange(SetAccessUtil accessUtil, RepoMetaDataUpdater repoMetaDataUpdater) {
     this.setAccess = accessUtil;
-    this.jsonFactory = jsonFactory;
     this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
-      throws PermissionBackendException, IOException, ConfigInvalidException, InvalidNameException,
-          UpdateException, RestApiException {
+      throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+          RestApiException {
     ImmutableList<AccessSection> removals =
         setAccess.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
     ImmutableList<AccessSection> additions =
@@ -63,28 +57,23 @@
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
-    String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Review access change";
-    try {
-      Change change =
-          repoMetaDataUpdater.updateAndCreateChangeForReview(
-              rsrc.getNameKey(),
-              rsrc.getUser(),
-              message,
-              config -> {
-                setAccess.validateChanges(config, removals, additions);
-                setAccess.applyChanges(config, removals, additions);
-                try {
-                  setAccess.setParentName(
-                      rsrc.getUser().asIdentifiedUser(),
-                      config,
-                      rsrc.getNameKey(),
-                      newParentProjectName,
-                      false);
-                } catch (AuthException e) {
-                  throw new IllegalStateException(e);
-                }
-              });
-      return Response.created(jsonFactory.noOptions().format(change));
+    try (ConfigChangeCreator creator =
+        repoMetaDataUpdater.configChangeCreator(
+            rsrc.getNameKey(), input.message, "Review access change")) {
+      ProjectConfig config = creator.getConfig();
+      setAccess.validateChanges(config, removals, additions);
+      setAccess.applyChanges(config, removals, additions);
+      try {
+        setAccess.setParentName(
+            rsrc.getUser().asIdentifiedUser(),
+            config,
+            rsrc.getNameKey(),
+            newParentProjectName,
+            false);
+      } catch (AuthException e) {
+        throw new IllegalStateException(e);
+      }
+      return creator.createChange();
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index a233834..12d1189 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -27,19 +27,13 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelDefinitionJson;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
@@ -49,27 +43,14 @@
 @Singleton
 public class CreateLabel
     implements RestCollectionCreateView<ProjectResource, LabelResource, LabelDefinitionInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   public CreateLabel(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      ApprovalQueryBuilder approvalQueryBuilder) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      ApprovalQueryBuilder approvalQueryBuilder, RepoMetaDataUpdater repoMetaDataUpdater) {
     this.approvalQueryBuilder = approvalQueryBuilder;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
@@ -77,15 +58,6 @@
       ProjectResource rsrc, IdString id, LabelDefinitionInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           PermissionBackendException, IOException, ConfigInvalidException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
     if (input == null) {
       input = new LabelDefinitionInput();
     }
@@ -93,22 +65,10 @@
     if (input.name != null && !input.name.equals(id.get())) {
       throw new BadRequestException("name in input must match name in URL");
     }
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      LabelType labelType = createLabel(config, id.get(), input);
-
-      if (input.commitMessage != null) {
-        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-      } else {
-        md.setMessage("Update label");
-      }
-
-      config.commit(md);
-
-      projectCache.evictAndReindex(rsrc.getProjectState().getProject());
-
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(rsrc.getNameKey(), input.commitMessage, "Update label")) {
+      LabelType labelType = createLabel(configUpdater.getConfig(), id.get(), input);
+      configUpdater.commitConfigUpdate();
       return Response.created(LabelDefinitionJson.format(rsrc.getNameKey(), labelType));
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
index a46211c..b946958 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -26,12 +26,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
@@ -39,7 +34,6 @@
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.gerrit.server.project.SubmitRequirementsUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -50,42 +44,42 @@
 public class CreateSubmitRequirement
     implements RestCollectionCreateView<
         ProjectResource, SubmitRequirementResource, SubmitRequirementInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+  private final RepoMetaDataUpdater updater;
 
   @Inject
   public CreateSubmitRequirement(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator,
+      RepoMetaDataUpdater updater) {
     this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+    this.updater = updater;
   }
 
   @Override
   public Response<SubmitRequirementInfo> apply(
       ProjectResource rsrc, IdString id, SubmitRequirementInput input)
       throws AuthException, BadRequestException, IOException, PermissionBackendException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
+    String defaultMessage = String.format("Create Submit Requirement %s", id.get());
+    try (var configUpdater =
+        updater.configUpdater(
+            rsrc.getNameKey(),
+            /** message= */
+            null,
+            defaultMessage)) {
+      ProjectConfig config = configUpdater.getConfig();
+      SubmitRequirement submitRequirement = updateConfig(config, id, input);
+
+      configUpdater.commitConfigUpdate();
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    } catch (ResourceConflictException e) {
+      throw new BadRequestException("Failed to create submit requirement", e);
     }
+  }
 
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+  SubmitRequirement updateConfig(ProjectConfig config, IdString id, SubmitRequirementInput input)
+      throws ResourceConflictException, BadRequestException {
     if (input == null) {
       input = new SubmitRequirementInput();
     }
@@ -93,23 +87,7 @@
     if (input.name != null && !input.name.equals(id.get())) {
       throw new BadRequestException("name in input must match name in URL");
     }
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      SubmitRequirement submitRequirement = createSubmitRequirement(config, id.get(), input);
-
-      md.setMessage(String.format("Create Submit Requirement %s", submitRequirement.name()));
-      config.commit(md);
-
-      projectCache.evict(rsrc.getProjectState().getProject().getNameKey());
-
-      return Response.created(SubmitRequirementJson.format(submitRequirement));
-    } catch (ConfigInvalidException e) {
-      throw new IOException("Failed to read project config", e);
-    } catch (ResourceConflictException e) {
-      throw new BadRequestException("Failed to create submit requirement", e);
-    }
+    return createSubmitRequirement(config, id.get(), input);
   }
 
   public SubmitRequirement createSubmitRequirement(
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
index 8a1927a..fcd72a3 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -14,84 +14,49 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.extensions.common.InputWithCommitMessage;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteLabel implements RestModifyView<LabelResource, InputWithCommitMessage> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  public DeleteLabel(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+  public DeleteLabel(RepoMetaDataUpdater repoMetaDataUpdater) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<?> apply(LabelResource rsrc, InputWithCommitMessage input)
       throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException,
-          ConfigInvalidException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
+          ConfigInvalidException, BadRequestException {
     if (input == null) {
       input = new InputWithCommitMessage();
     }
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(), input.commitMessage, "Delete label")) {
+      ProjectConfig config = configUpdater.getConfig();
 
       if (!deleteLabel(config, rsrc.getLabelType().getName())) {
         throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getLabelType().getName()));
       }
-
-      if (input.commitMessage != null) {
-        md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-      } else {
-        md.setMessage("Delete label");
-      }
-
-      config.commit(md);
+      configUpdater.commitConfigUpdate();
     }
 
-    projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
-
     return Response.none();
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
index 1be4a5f..64e2399 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
@@ -15,57 +15,30 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
 public class DeleteSubmitRequirement implements RestModifyView<SubmitRequirementResource, Input> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  public DeleteSubmitRequirement(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+  public DeleteSubmitRequirement(RepoMetaDataUpdater repoMetaDataUpdater) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<?> apply(SubmitRequirementResource rsrc, Input input) throws Exception {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(), null, "Delete submit requirement")) {
+      ProjectConfig config = configUpdater.getConfig();
 
       if (!deleteSubmitRequirement(config, rsrc.getSubmitRequirement().name())) {
         // This code is unreachable because the exception is thrown when rsrc was parsed
@@ -75,12 +48,9 @@
                 IdString.fromDecoded(rsrc.getSubmitRequirement().name())));
       }
 
-      md.setMessage("Delete submit requirement");
-      config.commit(md);
+      configUpdater.commitConfigUpdate();
     }
 
-    projectCache.evict(rsrc.getProject().getProjectState().getProject().getNameKey());
-
     return Response.none();
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index ec42035..698cf58 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -14,24 +14,18 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -39,53 +33,30 @@
 
 @Singleton
 public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
-  private final ProjectCache cache;
-  private final Provider<MetaDataUpdate.Server> updateFactory;
-  private final PermissionBackend permissionBackend;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
-  PutDescription(
-      ProjectCache cache,
-      Provider<MetaDataUpdate.Server> updateFactory,
-      PermissionBackend permissionBackend,
-      ProjectConfig.Factory projectConfigFactory) {
-    this.cache = cache;
-    this.updateFactory = updateFactory;
-    this.permissionBackend = permissionBackend;
-    this.projectConfigFactory = projectConfigFactory;
+  PutDescription(RepoMetaDataUpdater repoMetaDataUpdater) {
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<String> apply(ProjectResource resource, DescriptionInput input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException, IOException,
-          PermissionBackendException {
+          PermissionBackendException, BadRequestException {
     if (input == null) {
       input = new DescriptionInput(); // Delete would set description to null.
     }
 
-    IdentifiedUser user = resource.getUser().asIdentifiedUser();
-    permissionBackend
-        .user(user)
-        .project(resource.getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
-    try (MetaDataUpdate md = updateFactory.get().create(resource.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            resource.getNameKey(), input.commitMessage, "Update description")) {
+      ProjectConfig config = configUpdater.getConfig();
       String desc = input.description;
       config.updateProject(p -> p.setDescription(Strings.emptyToNull(desc)));
 
-      String msg =
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(input.commitMessage), "Update description\n");
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(user);
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evictAndReindex(resource.getProjectState().getProject());
-      md.getRepository().setGitwebDescription(config.getProject().getDescription());
+      configUpdater.commitConfigUpdate();
+      configUpdater.getRepository().setGitwebDescription(config.getProject().getDescription());
 
       return Strings.isNullOrEmpty(config.getProject().getDescription())
           ? Response.none()
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
index c45a009..5aea2c5 100644
--- a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -15,24 +15,28 @@
 package com.google.gerrit.server.restapi.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.MustBeClosed;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.MetaDataUpdate.User;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -49,18 +53,20 @@
 import javax.inject.Inject;
 import javax.inject.Provider;
 import javax.inject.Singleton;
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Updates repo refs/meta/config content. */
 @Singleton
 public class RepoMetaDataUpdater {
-  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final Provider<User> metaDataUpdateFactory;
+  private final Provider<CurrentUser> user;
   private final ProjectConfig.Factory projectConfigFactory;
   private final ProjectCache projectCache;
   private final ChangeInserter.Factory changeInserterFactory;
@@ -69,38 +75,61 @@
   private final BatchUpdate.Factory updateFactory;
 
   private final PermissionBackend permissionBackend;
+  private final ChangeJson.Factory jsonFactory;
 
   @Inject
   RepoMetaDataUpdater(
-      CreateGroupPermissionSyncer createGroupPermissionSyncer,
       Provider<User> metaDataUpdateFactory,
+      Provider<CurrentUser> user,
       ProjectConfig.Factory projectConfigFactory,
       ProjectCache projectCache,
       ChangeInserter.Factory changeInserterFactory,
       Sequences seq,
       BatchUpdate.Factory updateFactory,
-      PermissionBackend permissionBackend) {
-    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+      PermissionBackend permissionBackend,
+      ChangeJson.Factory jsonFactory) {
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.user = user;
     this.projectConfigFactory = projectConfigFactory;
     this.projectCache = projectCache;
     this.changeInserterFactory = changeInserterFactory;
     this.seq = seq;
     this.updateFactory = updateFactory;
     this.permissionBackend = permissionBackend;
+    this.jsonFactory = jsonFactory;
   }
 
-  public Change updateAndCreateChangeForReview(
-      Project.NameKey projectName,
-      CurrentUser user,
-      String message,
-      ProjectConfigUpdater projectConfigUpdater)
-      throws ConfigInvalidException, IOException, RestApiException, UpdateException,
-          InvalidNameException, PermissionBackendException {
-    checkArgument(!message.isBlank(), "The message must not be empty");
-    message = validateMessage(message);
-
-    PermissionBackend.ForProject forProject = permissionBackend.user(user).project(projectName);
+  /**
+   * Returns a creator for creating project config changes.
+   *
+   * <p>The method checks that user has required permissions.
+   *
+   * <p>Usage:
+   *
+   * <pre>{@code
+   * try(var changeCreator =
+   *  repoMetaDataUpdater.configChangeCreator(projectName, message, defaultMessage)) {
+   *    ProjectConfig config = changeCreator.getConfig();
+   *    // ... update project config
+   *    // Create change - if the createChange method is not called, all updates are ignored and no
+   *    // change is created.
+   *    Response<ChangeInfo> result = changeCreator.createChange();
+   *  }
+   * }</pre>
+   *
+   * @param projectName the name of the project whose config should be updated
+   * @param message the user-provided commit message. If it is not provided (i.e. it is null or
+   *     empty) - the {@code defaultMessage} is used.
+   * @param defaultMessage the default commit message if the user doesn't provide one.
+   */
+  @MustBeClosed
+  public ConfigChangeCreator configChangeCreator(
+      Project.NameKey projectName, @Nullable String message, String defaultMessage)
+      throws PermissionBackendException, AuthException, ResourceConflictException, IOException,
+          ConfigInvalidException {
+    message = validateMessage(message, defaultMessage);
+    PermissionBackend.ForProject forProject =
+        permissionBackend.user(user.get()).project(projectName);
     if (!check(forProject, ProjectPermission.READ_CONFIG)) {
       throw new AuthException(RefNames.REFS_CONFIG + " not visible");
     }
@@ -112,15 +141,153 @@
       }
     }
     projectCache.get(projectName).orElseThrow(illegalState(projectName)).checkStatePermitsWrite();
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig config = projectConfigFactory.read(md);
-      ObjectId oldCommit = config.getRevision();
-      String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
-
-      projectConfigUpdater.update(config);
-      md.setMessage(message);
+    // The MetaDataUpdate instance gets closed in the ConfigChangeCreator.close() method.
+    MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName);
+    try {
       md.setInsertChangeId(true);
+      md.setMessage(message);
+      ProjectConfig config = projectConfigFactory.read(md);
+      return new ConfigChangeCreator(md, projectName, user.get(), config);
+    } catch (Throwable t) {
+      try (md) {
+        throw t;
+      }
+    }
+  }
+
+  /**
+   * Returns an updater for updating project config without review.
+   *
+   * <p>The method checks that user has required permissions.
+   *
+   * <p>When the update is saved (using the {@link ConfigUpdater#commitConfigUpdate} method), the
+   * project cache is updated automatically.
+   *
+   * <p>Usage:
+   *
+   * <pre>{@code
+   * try(var configUpdater =
+   *  repoMetaDataUpdater.configUpdater(projectName, message, defaultMessage)) {
+   *    ProjectConfig config = changeCreator.getConfig();
+   *    // ... update project config
+   *    // Save updated config - if the commitConfigUpdate method is not called, all updates are ignored.
+   *    configUpdater.commitConfigUpdate();
+   *  }
+   * }</pre>
+   *
+   * @param projectName the name of the project whose config should be updated
+   * @param message the user-provided commit message. If it is not provided (i.e. it is null or
+   *     empty) - the {@code defaultMessage} is used.
+   * @param defaultMessage the default commit message if the user doesn't provide one.
+   */
+  @MustBeClosed
+  public ConfigUpdater configUpdater(
+      Project.NameKey projectName, @Nullable String message, String defaultMessage)
+      throws AuthException, PermissionBackendException, ConfigInvalidException, IOException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    permissionBackend.user(user.get()).project(projectName).check(ProjectPermission.WRITE_CONFIG);
+    return configUpdaterWithoutPermissionsCheck(projectName, message, defaultMessage);
+  }
+
+  /**
+   * Returns an updater for updating project config without review and skips some permissions
+   * checks.
+   *
+   * <p>The method doesn't do any permissions checks. It should be used only when standard
+   * permissions checks from {@link #configUpdater} can't be used.
+   *
+   * <p>See {@link #configUpdater} for details.
+   */
+  @MustBeClosed
+  public ConfigUpdater configUpdaterWithoutPermissionsCheck(
+      Project.NameKey projectName, @Nullable String message, String defaultMessage)
+      throws IOException, ConfigInvalidException {
+    message = validateMessage(message, defaultMessage);
+    // The MetaDataUpdate instance gets closed in the ConfigUpdater.close() method.
+    MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName);
+    try {
+      ProjectConfig config = projectConfigFactory.read(md);
+      md.setMessage(message);
+      return new ConfigUpdater(md, config);
+    } catch (Throwable t) {
+      try (md) {
+        throw t;
+      }
+    }
+  }
+
+  /**
+   * Updater for a project config without review.
+   *
+   * <p>See {@link #configUpdater} and {@link #configUpdaterWithoutPermissionsCheck} for details and
+   * usages.
+   */
+  public class ConfigUpdater implements AutoCloseable {
+    private final MetaDataUpdate md;
+    private final ProjectConfig config;
+
+    private ConfigUpdater(MetaDataUpdate md, ProjectConfig config) {
+      this.md = md;
+      this.config = config;
+    }
+
+    public ProjectConfig getConfig() {
+      return config;
+    }
+
+    public void commitConfigUpdate() throws IOException {
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
+    }
+
+    public Repository getRepository() {
+      return md.getRepository();
+    }
+
+    @Override
+    public void close() {
+      md.close();
+    }
+  }
+
+  /**
+   * Creates a change for a project config update.
+   *
+   * <p>See {@link #createChange} for details and usages.
+   */
+  public class ConfigChangeCreator implements AutoCloseable {
+    private final MetaDataUpdate md;
+    private final String oldCommitSha1;
+    private final Project.NameKey projectName;
+    private final CurrentUser user;
+    private final ProjectConfig config;
+    private boolean changeCreated;
+
+    private ConfigChangeCreator(
+        MetaDataUpdate md, Project.NameKey projectName, CurrentUser user, ProjectConfig config) {
+      this.md = md;
+      this.config = config;
+      this.projectName = projectName;
+      this.user = user;
+      ObjectId oldCommit = config.getRevision();
+      oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
+    }
+
+    @Override
+    public void close() {
+      md.close();
+    }
+
+    public ProjectConfig getConfig() {
+      return config;
+    }
+
+    public Response<ChangeInfo> createChange()
+        throws IOException, UpdateException, RestApiException {
+      checkState(!changeCreated, "Change has been already created");
+      changeCreated = true;
 
       Change.Id changeId = Change.id(seq.nextChangeId());
       try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
@@ -140,62 +307,38 @@
           ChangeInserter ins = newInserter(changeId, commit);
           bu.insertChange(ins);
           bu.execute();
-          return ins.getChange();
+          Change change = ins.getChange();
+          return Response.created(jsonFactory.noOptions().format(change));
         }
       }
     }
-  }
 
-  public void updateWithoutReview(
-      Project.NameKey projectName, String message, ProjectConfigUpdater projectConfigUpdater)
-      throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
-          ResourceConflictException, InvalidNameException, BadRequestException {
-    updateWithoutReview(
-        projectName, message, /*skipPermissionsCheck=*/ false, projectConfigUpdater);
-  }
-
-  public void updateWithoutReview(
-      Project.NameKey projectName,
-      String message,
-      boolean skipPermissionsCheck,
-      ProjectConfigUpdater projectConfigUpdater)
-      throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
-          ResourceConflictException, InvalidNameException, BadRequestException {
-    message = validateMessage(message);
-    if (!skipPermissionsCheck) {
-      permissionBackend.currentUser().project(projectName).check(ProjectPermission.WRITE_CONFIG);
-    }
-
-    try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
-      ProjectConfig config = projectConfigFactory.read(md);
-
-      projectConfigUpdater.update(config);
-      md.setMessage(message);
-      config.commit(md);
-      projectCache.evictAndReindex(config.getProject());
-      createGroupPermissionSyncer.syncIfNeeded();
+    // ProjectConfig doesn't currently support fusing into a BatchUpdate.
+    @SuppressWarnings("deprecation")
+    private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
+      return changeInserterFactory
+          .create(changeId, commit, RefNames.REFS_CONFIG)
+          .setMessage(
+              // Same message as in ReceiveCommits.CreateRequest.
+              ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
+          .setValidate(false)
+          .setUpdateRef(false);
     }
   }
 
-  private String validateMessage(String message) {
+  private String validateMessage(@Nullable String message, String defaultMessage) {
+    if (Strings.isNullOrEmpty(message)) {
+      message = defaultMessage;
+    } else {
+      message = message.trim();
+    }
+    checkArgument(!message.isBlank(), "The message must not be empty");
     if (!message.endsWith("\n")) {
       return message + "\n";
     }
     return message;
   }
 
-  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
-  @SuppressWarnings("deprecation")
-  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
-    return changeInserterFactory
-        .create(changeId, commit, RefNames.REFS_CONFIG)
-        .setMessage(
-            // Same message as in ReceiveCommits.CreateRequest.
-            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
-        .setValidate(false)
-        .setUpdateRef(false);
-  }
-
   private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
       throws PermissionBackendException {
     try {
@@ -205,11 +348,4 @@
       return false;
     }
   }
-
-  @FunctionalInterface
-  public interface ProjectConfigUpdater {
-    void update(ProjectConfig config)
-        throws BadRequestException, InvalidNameException, PermissionBackendException,
-            ResourceConflictException, AuthException;
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 75fe280..65851c0 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.AccessSection;
@@ -29,12 +28,15 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -49,6 +51,7 @@
   private final Provider<IdentifiedUser> identifiedUser;
   private final SetAccessUtil accessUtil;
   private final RepoMetaDataUpdater repoMetaDataUpdater;
+  private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
 
   @Inject
   private SetAccess(
@@ -57,6 +60,7 @@
       GetAccess getAccess,
       Provider<IdentifiedUser> identifiedUser,
       SetAccessUtil accessUtil,
+      CreateGroupPermissionSyncer createGroupPermissionSyncer,
       RepoMetaDataUpdater repoMetaDataUpdater) {
     this.groupBackend = groupBackend;
     this.permissionBackend = permissionBackend;
@@ -64,6 +68,7 @@
     this.identifiedUser = identifiedUser;
     this.accessUtil = accessUtil;
     this.repoMetaDataUpdater = repoMetaDataUpdater;
+    this.createGroupPermissionSyncer = createGroupPermissionSyncer;
   }
 
   @Override
@@ -75,42 +80,40 @@
         accessUtil.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
     ImmutableList<AccessSection> additions =
         accessUtil.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
-    String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Modify access rules";
-    try {
-      this.repoMetaDataUpdater.updateWithoutReview(
+
+    try (ConfigUpdater updater =
+        repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(
+            rsrc.getNameKey(), input.message, "Modify access rules")) {
+      ProjectConfig config = updater.getConfig();
+      boolean checkedAdmin = false;
+      for (AccessSection section : Iterables.concat(additions, removals)) {
+        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+        if (isGlobalCapabilities) {
+          if (!checkedAdmin) {
+            permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+            checkedAdmin = true;
+          }
+        } else {
+          permissionBackend
+              .currentUser()
+              .project(rsrc.getNameKey())
+              .ref(section.getName())
+              .check(RefPermission.WRITE_CONFIG);
+        }
+      }
+
+      accessUtil.validateChanges(config, removals, additions);
+      accessUtil.applyChanges(config, removals, additions);
+
+      accessUtil.setParentName(
+          identifiedUser.get(),
+          config,
           rsrc.getNameKey(),
-          message,
-          /*skipPermissionsCheck=*/ true,
-          config -> {
-            // Check that the user has the right permissions.
-            boolean checkedAdmin = false;
-            for (AccessSection section : Iterables.concat(additions, removals)) {
-              boolean isGlobalCapabilities =
-                  AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
-              if (isGlobalCapabilities) {
-                if (!checkedAdmin) {
-                  permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
-                  checkedAdmin = true;
-                }
-              } else {
-                permissionBackend
-                    .currentUser()
-                    .project(rsrc.getNameKey())
-                    .ref(section.getName())
-                    .check(RefPermission.WRITE_CONFIG);
-              }
-            }
+          input.parent == null ? null : Project.nameKey(input.parent),
+          !checkedAdmin);
 
-            accessUtil.validateChanges(config, removals, additions);
-            accessUtil.applyChanges(config, removals, additions);
-
-            accessUtil.setParentName(
-                identifiedUser.get(),
-                config,
-                rsrc.getNameKey(),
-                input.parent == null ? null : Project.nameKey(input.parent),
-                !checkedAdmin);
-          });
+      updater.commitConfigUpdate();
+      createGroupPermissionSyncer.syncIfNeeded();
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
     } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index 853d7df..a46ee32 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
@@ -25,12 +24,8 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.DashboardResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
@@ -41,30 +36,21 @@
 import org.kohsuke.args4j.Option;
 
 class SetDefaultDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
-  private final ProjectCache cache;
-  private final MetaDataUpdate.Server updateFactory;
   private final DashboardsCollection dashboards;
   private final Provider<GetDashboard> get;
-  private final PermissionBackend permissionBackend;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Option(name = "--inherited", usage = "set dashboard inherited by children")
   boolean inherited;
 
   @Inject
   SetDefaultDashboard(
-      ProjectCache cache,
-      MetaDataUpdate.Server updateFactory,
       DashboardsCollection dashboards,
       Provider<GetDashboard> get,
-      PermissionBackend permissionBackend,
-      ProjectConfig.Factory projectConfigFactory) {
-    this.cache = cache;
-    this.updateFactory = updateFactory;
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.dashboards = dashboards;
     this.get = get;
-    this.permissionBackend = permissionBackend;
-    this.projectConfigFactory = projectConfigFactory;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
@@ -75,11 +61,6 @@
     }
     input.id = Strings.emptyToNull(input.id);
 
-    permissionBackend
-        .user(rsrc.getUser())
-        .project(rsrc.getProjectState().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
     DashboardResource target = null;
     if (input.id != null) {
       try {
@@ -93,29 +74,22 @@
         throw new ResourceConflictException(e.getMessage());
       }
     }
+    String defaultMessage =
+        input.id == null
+            ? "Removed default dashboard.\n"
+            : String.format("Changed default dashboard to %s.\n", input.id);
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProjectState().getNameKey(), input.commitMessage, defaultMessage)) {
+      ProjectConfig config = configUpdater.getConfig();
       String id = input.id;
       if (inherited) {
         config.updateProject(p -> p.setDefaultDashboard(id));
       } else {
         config.updateProject(p -> p.setLocalDefaultDashboard(id));
       }
-
-      String msg =
-          MoreObjects.firstNonNull(
-              Strings.emptyToNull(input.commitMessage),
-              input.id == null
-                  ? "Removed default dashboard.\n"
-                  : String.format("Changed default dashboard to %s.\n", input.id));
-      if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(rsrc.getUser().asIdentifiedUser());
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evictAndReindex(rsrc.getProjectState().getProject());
+      configUpdater.commitConfigUpdate();
 
       if (target != null) {
         Response<DashboardInfo> response = get.get().apply(target);
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index edd165d..1553eda 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -24,18 +24,12 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.LabelDefinitionJson;
 import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -43,63 +37,36 @@
 
 @Singleton
 public class SetLabel implements RestModifyView<LabelResource, LabelDefinitionInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   public SetLabel(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      ApprovalQueryBuilder approvalQueryBuilder) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      ApprovalQueryBuilder approvalQueryBuilder, RepoMetaDataUpdater repoMetaDataUpdater) {
     this.approvalQueryBuilder = approvalQueryBuilder;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<LabelDefinitionInfo> apply(LabelResource rsrc, LabelDefinitionInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           PermissionBackendException, IOException, ConfigInvalidException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
     if (input == null) {
       input = new LabelDefinitionInput();
     }
 
     LabelType labelType = rsrc.getLabelType();
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(), input.commitMessage, "Update label")) {
+      ProjectConfig config = configUpdater.getConfig();
 
       if (updateLabel(config, labelType, input)) {
-        if (input.commitMessage != null) {
-          md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
-        } else {
-          md.setMessage("Update label");
-        }
         String newName = Strings.nullToEmpty(input.name).trim();
         labelType =
             config.getLabelSections().get(newName.isEmpty() ? labelType.getName() : newName);
-
-        config.commit(md);
-        projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
+        configUpdater.commitConfigUpdate();
       }
     }
     return Response.ok(LabelDefinitionJson.format(rsrc.getProject().getNameKey(), labelType));
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index ef31dc5..ad8bc8a 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -48,7 +47,6 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -60,29 +58,26 @@
     implements RestModifyView<ProjectResource, ParentInput>, GerritConfigListener {
   private final ProjectCache cache;
   private final PermissionBackend permissionBackend;
-  private final Provider<MetaDataUpdate.Server> updateFactory;
   private final AllProjectsName allProjects;
   private final AllUsersName allUsers;
-  private final ProjectConfig.Factory projectConfigFactory;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
   private volatile boolean allowProjectOwnersToChangeParent;
 
   @Inject
   SetParent(
       ProjectCache cache,
       PermissionBackend permissionBackend,
-      Provider<MetaDataUpdate.Server> updateFactory,
       AllProjectsName allProjects,
       AllUsersName allUsers,
-      ProjectConfig.Factory projectConfigFactory,
-      @GerritServerConfig Config config) {
+      @GerritServerConfig Config config,
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.cache = cache;
     this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
     this.allProjects = allProjects;
     this.allUsers = allUsers;
-    this.projectConfigFactory = projectConfigFactory;
     this.allowProjectOwnersToChangeParent =
         config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
@@ -101,20 +96,14 @@
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
     validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
-    try (MetaDataUpdate md = updateFactory.get().create(rsrc.getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(
+            rsrc.getNameKey(),
+            input.commitMessage,
+            String.format("Changed parent to %s.\n", parentName))) {
+      ProjectConfig config = configUpdater.getConfig();
       config.updateProject(p -> p.setParent(parentName));
-
-      String msg = Strings.emptyToNull(input.commitMessage);
-      if (msg == null) {
-        msg = String.format("Changed parent to %s.\n", parentName);
-      } else if (!msg.endsWith("\n")) {
-        msg += "\n";
-      }
-      md.setAuthor(user);
-      md.setMessage(msg);
-      config.commit(md);
-      cache.evictAndReindex(rsrc.getProjectState().getProject());
+      configUpdater.commitConfigUpdate();
 
       Project.NameKey parent = config.getProject().getParent(allProjects);
       requireNonNull(parent);
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
index 3e1104e..7eb2665 100644
--- a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -24,19 +24,13 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
 import com.google.gerrit.server.project.SubmitRequirementJson;
 import com.google.gerrit.server.project.SubmitRequirementResource;
 import com.google.gerrit.server.project.SubmitRequirementsUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
@@ -48,42 +42,21 @@
 @Singleton
 public class UpdateSubmitRequirement
     implements RestModifyView<SubmitRequirementResource, SubmitRequirementInput> {
-  private final Provider<CurrentUser> user;
-  private final PermissionBackend permissionBackend;
-  private final MetaDataUpdate.User updateFactory;
-  private final ProjectConfig.Factory projectConfigFactory;
-  private final ProjectCache projectCache;
   private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+  private final RepoMetaDataUpdater repoMetaDataUpdater;
 
   @Inject
   public UpdateSubmitRequirement(
-      Provider<CurrentUser> user,
-      PermissionBackend permissionBackend,
-      MetaDataUpdate.User updateFactory,
-      ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache,
-      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
-    this.user = user;
-    this.permissionBackend = permissionBackend;
-    this.updateFactory = updateFactory;
-    this.projectConfigFactory = projectConfigFactory;
-    this.projectCache = projectCache;
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator,
+      RepoMetaDataUpdater repoMetaDataUpdater) {
     this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+    this.repoMetaDataUpdater = repoMetaDataUpdater;
   }
 
   @Override
   public Response<SubmitRequirementInfo> apply(
       SubmitRequirementResource rsrc, SubmitRequirementInput input)
       throws AuthException, BadRequestException, PermissionBackendException, IOException {
-    if (!user.get().isIdentifiedUser()) {
-      throw new AuthException("Authentication required");
-    }
-
-    permissionBackend
-        .currentUser()
-        .project(rsrc.getProject().getNameKey())
-        .check(ProjectPermission.WRITE_CONFIG);
-
     if (input == null) {
       input = new SubmitRequirementInput();
     }
@@ -92,16 +65,17 @@
       throw new BadRequestException("name in input must match name in URL");
     }
 
-    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
-      ProjectConfig config = projectConfigFactory.read(md);
+    try (var configUpdater =
+        repoMetaDataUpdater.configUpdater(
+            rsrc.getProject().getNameKey(),
+            null,
+            String.format("Update Submit Requirement %s", rsrc.getSubmitRequirement().name()))) {
+      ProjectConfig config = configUpdater.getConfig();
 
       SubmitRequirement submitRequirement =
-          createSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
+          updateSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
 
-      md.setMessage(String.format("Update Submit Requirement %s", submitRequirement.name()));
-      config.commit(md);
-
-      projectCache.evict(rsrc.getProject().getNameKey());
+      configUpdater.commitConfigUpdate();
 
       return Response.created(SubmitRequirementJson.format(submitRequirement));
     } catch (ConfigInvalidException e) {
@@ -109,7 +83,7 @@
     }
   }
 
-  public SubmitRequirement createSubmitRequirement(
+  public SubmitRequirement updateSubmitRequirement(
       ProjectConfig config, String name, SubmitRequirementInput input) throws BadRequestException {
     validateSRName(name);
     if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index b9cbbcd..2ce8f78 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
@@ -102,6 +103,10 @@
     DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().dashboard(info.id).setDefault();
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG,
+        "Administrator",
+        String.format("Changed default dashboard to %s.", info.id));
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
     assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
   }
@@ -111,10 +116,16 @@
     DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().defaultDashboard(info.id);
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG,
+        "Administrator",
+        String.format("Changed default dashboard to %s.", info.id));
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
     assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
 
     project().removeDefaultDashboard();
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "Removed default dashboard.");
     assertThat(project().dashboard(info.id).get().isDefault).isNull();
 
     assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
diff --git a/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
new file mode 100644
index 0000000..befb311
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
+import org.junit.Test;
+
+public class PutDescriptionIT extends AbstractDaemonTest {
+  @Test
+  public void setDescription() throws Exception {
+    DescriptionInput input = new DescriptionInput();
+    input.description = "test project description";
+    gApi.projects().name(project.get()).description(input);
+    assertThat(gApi.projects().name(project.get()).description())
+        .isEqualTo("test project description");
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "Update description");
+  }
+
+  @Test
+  public void setDescriptionWithCustomCommitMessage() throws Exception {
+    DescriptionInput input = new DescriptionInput();
+    input.description = "test project description with test commit message";
+    input.commitMessage = "test commit message";
+    gApi.projects().name(project.get()).description(input);
+    assertThat(gApi.projects().name(project.get()).description())
+        .isEqualTo("test project description with test commit message");
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "test commit message");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 2bdbe50..a839b6a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -89,12 +90,16 @@
 
     gApi.projects().name(project.get()).parent(parent);
     assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", String.format("Changed parent to %s.", parent));
 
     // When the parent name is not explicitly set, it should be
     // set to "All-Projects".
     gApi.projects().name(project.get()).parent(null);
     assertThat(gApi.projects().name(project.get()).parent())
         .isEqualTo(AllProjectsNameProvider.DEFAULT);
+    assertLastCommitAuthorAndShortMessage(
+        RefNames.REFS_CONFIG, "Administrator", "Changed parent to All-Projects.");
   }
 
   @Test