Merge "Add REST API to commit file changes to a branch"
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 824a757..fd58ed4 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -2135,6 +2135,89 @@
   Ly8gQ29weXJpZ2h0IChDKSAyMDEwIFRoZSBBbmRyb2lkIE9wZW4gU291cmNlIFByb2plY...
 ----
 
+[[create-commit]]
+=== Create Commit
+--
+'POST /projects/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]/commit'
+--
+
+Creates a single commit that applies a set of file operations (create/update,
+delete, rename) directly to the branch, for CI and automation use cases (no clone
+or multi-step change workflow required).
+
+This is a server-side branch update, not a `git push`. The caller needs the
+link:access-control.html#category_push[Push] access right (a fast-forward
+`UPDATE`) on a writable, ordinary branch (not `HEAD`, a tag, or `refs/meta/*`).
+Writing to `HEAD` is rejected with "`405 Method Not Allowed`"; the caller must
+target the branch `HEAD` points to instead.
+Ref-update and commit validators run, including validators contributed by
+plugins, and validation failures are returned as an error. There is no
+receive-pack, so push-only policies are not applied: signed-push /
+push-certificate verification is skipped, and receive-pack object checks such
+as link:config-gerrit.html#receive.maxObjectSizeLimit[receive.maxObjectSizeLimit]
+are not enforced. Matching open changes are not auto-closed.
+
+The request body is a link:#create-commit-input[CreateCommitInput] entity that
+lists the file operations and the commit message.
+
+.Request
+----
+  POST /projects/MyProject/branches/master/commit HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "commit_message": "Update configuration files",
+    "files": {
+      "conf/app.config": {
+        "content": "a2V5ID0gdmFsdWUK"
+      },
+      "bin/run.sh": {
+        "content": "IyEvYmluL3NoCmVjaG8gaGkK",
+        "file_mode": 100755
+      },
+      "conf/obsolete.config": {
+        "delete": true
+      },
+      "conf/renamed.config": {
+        "rename_from": "conf/old.config"
+      }
+    }
+  }
+----
+
+As response the link:rest-api-changes.html#commit-info[CommitInfo] of the new
+commit is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "commit": "84276d7ab324c9a50f8db21375e1a49f2a2e970f",
+    "subject": "Update configuration files",
+    "message": "Update configuration files\n"
+  }
+----
+
+The file operations are applied atomically: the request either creates a single
+commit containing all listed operations and advances the branch to it, or fails
+without modifying the branch.
+
+If `base_revision` is set in the input and the branch no longer points at that
+commit (for example because of a concurrent update), the request is rejected with
+"`409 Conflict`", so callers can retry without clobbering the other change. A
+request that would not change the tree is rejected with "`400 Bad Request`".
+
+The new commit is authored and committed by the calling user, using the
+account's full name and preferred email address. If the account has no
+preferred email, a generic `username@host` identity is used instead. Author
+and committer timestamps are the server time at which the commit was created.
+Neither the identities nor the timestamps can be overridden through this
+endpoint.
+
 [[validation-options]]
 === Get Validation Options
 --
@@ -4656,6 +4739,56 @@
 Whether to skip adding the Git commit author and committer as reviewers for a new change.
 |=======================================================
 
+[[create-commit-input]]
+=== CreateCommitInput
+The `CreateCommitInput` entity describes the file operations to apply to a
+branch as a single commit, used by link:#create-commit[Create Commit].
+
+[options="header",cols="1,^2,4"]
+|======================================================
+|Field Name    ||Description
+|`commit_message` ||
+The commit message. Must be non-empty.
+|`base_revision`|optional|
+The commit (SHA-1) the target branch is expected to point at: the request is
+rejected with "`409 Conflict`" if the branch tip is any other commit (optimistic
+concurrency). This is a compare-and-swap check only; the new commit is always
+created on top of the current branch tip, and `base_revision` does not select an
+older base to commit onto. A value that is not a full 40-character SHA-1 is
+rejected with "`400 Bad Request`".
+|`files`       ||
+A map of file path to link:#file-change[FileChange] describing the operation to
+apply at that path. Applied together as one commit.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the ref-operation and
+commit validation listeners (e.g. to skip certain validations). Which options are
+supported depends on the installed validation listeners; Gerrit core supports
+none. Unknown options are silently ignored.
+|======================================================
+
+[[file-change]]
+=== FileChange
+The `FileChange` entity describes a single file operation within a
+link:#create-commit-input[CreateCommitInput]. Exactly one of `content`, `delete`, or
+`rename_from` must be set.
+
+[options="header",cols="1,^2,4"]
+|======================================================
+|Field Name    ||Description
+|`content`     |optional|
+The new file content, base64-encoded, for a create or update. For a `120000`
+(symlink) entry, the decoded content is the symlink target path.
+|`file_mode`   |optional|
+The file mode in octal format (`100644` regular file, `100755` executable,
+`120000` symlink). If not set, new files are created as `100644` and existing
+files keep their mode.
+|`delete`      |optional|
+If `true`, deletes the file at this path.
+|`rename_from` |optional|
+Source path to rename from. The file at `rename_from` is moved to this entry's
+path.
+|======================================================
+
 [[config-input]]
 === ConfigInput
 The `ConfigInput` entity describes a new project configuration.
diff --git a/java/com/google/gerrit/acceptance/GerritServerRestSession.java b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
index f244c2d..92409c3 100644
--- a/java/com/google/gerrit/acceptance/GerritServerRestSession.java
+++ b/java/com/google/gerrit/acceptance/GerritServerRestSession.java
@@ -123,6 +123,17 @@
     return execute(post);
   }
 
+  @Override
+  public RestResponse postRaw(String endPoint, RawInput stream) throws IOException {
+    requireNonNull(stream);
+    Request post = Request.Post(getUrl(endPoint));
+    post.addHeader(new BasicHeader(CONTENT_TYPE, stream.getContentType()));
+    post.body(
+        new BufferedHttpEntity(
+            new InputStreamEntity(stream.getInputStream(), stream.getContentLength())));
+    return execute(post);
+  }
+
   private static void addContentToRequest(Request request, Object content) {
     request.addHeader(new BasicHeader(CONTENT_TYPE, "application/json"));
     request.body(new StringEntity(JSON_COMPACT.newGson().toJson(content), UTF_8));
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 3fefd5b..3240472 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -49,6 +49,8 @@
 
   RestResponse postWithHeaders(String endPoint, Object content, Header... headers) throws Exception;
 
+  RestResponse postRaw(String endPoint, RawInput stream) throws Exception;
+
   RestResponse delete(String endPoint) throws Exception;
 
   RestResponse deleteWithHeaders(String endPoint, Header... headers) throws Exception;
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchApi.java b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
index 5e82bdb..3c776bb 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
@@ -16,6 +16,7 @@
 
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.api.changes.ChangeApi.SuggestedReviewersRequest;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.ValidationOptionInfos;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -32,6 +33,12 @@
   /** Returns the content of a file from the HEAD revision. */
   BinaryResult file(String path) throws RestApiException;
 
+  /**
+   * Commits a set of file operations (write/create, delete, rename) to the branch as one commit and
+   * returns the new commit.
+   */
+  CommitInfo createCommit(CreateCommitInput input) throws RestApiException;
+
   List<ReflogEntryInfo> reflog() throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
diff --git a/java/com/google/gerrit/extensions/api/projects/CreateCommitInput.java b/java/com/google/gerrit/extensions/api/projects/CreateCommitInput.java
new file mode 100644
index 0000000..91d2335
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/CreateCommitInput.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import java.util.Map;
+
+/**
+ * Input for creating a commit that applies a set of file operations to a branch in a single call.
+ */
+public class CreateCommitInput {
+  /** Commit message. */
+  public String commitMessage;
+
+  /**
+   * Optional base commit (SHA-1).
+   *
+   * <p>This is the expected current commit of the target branch: the request is rejected if the
+   * branch no longer points at it (optimistic concurrency / lost-update protection). When unset,
+   * the current branch tip is used.
+   */
+  public String baseRevision;
+
+  /**
+   * File operations to apply, keyed by file path. Each entry either writes/creates content, deletes
+   * the file, or renames another file to this path. Applied together as one commit.
+   */
+  public Map<String, FileChange> files;
+
+  /**
+   * Validation options as key-value pairs that are forwarded as options to the ref-operation and
+   * commit validation listeners (e.g. to skip certain validations). Which options are supported
+   * depends on the installed validation listeners; Gerrit core supports none. Unknown options are
+   * silently ignored.
+   */
+  public Map<String, String> validationOptions;
+
+  /** A single file operation within a {@link CreateCommitInput}. */
+  public static class FileChange {
+    /**
+     * New file content, base64-encoded, for a write or create. For a {@code 120000} (symlink)
+     * entry, the decoded content is the symlink target path. Mutually exclusive with {@link
+     * #delete} and {@link #renameFrom}.
+     */
+    public String content;
+
+    /**
+     * File mode in octal format. Supported values are {@code 100644} (regular file), {@code 100755}
+     * (executable file) and {@code 120000} (symlink). If unset, new files are created as {@code
+     * 100644} and existing files keep their mode.
+     */
+    public Integer fileMode;
+
+    /**
+     * When {@code true}, deletes the file at this path. Mutually exclusive with the other fields.
+     */
+    public boolean delete;
+
+    /**
+     * Source path to rename from. The file at {@code renameFrom} is moved to this entry's path.
+     * Mutually exclusive with {@link #content} and {@link #delete}.
+     */
+    public String renameFrom;
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 6af6dfd..8dc6d06 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -21,7 +21,9 @@
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.common.ValidationOptionInfos;
@@ -34,6 +36,7 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.restapi.project.BranchesCollection;
 import com.google.gerrit.server.restapi.project.CreateBranch;
+import com.google.gerrit.server.restapi.project.CreateCommit;
 import com.google.gerrit.server.restapi.project.DeleteBranch;
 import com.google.gerrit.server.restapi.project.FilesCollection;
 import com.google.gerrit.server.restapi.project.GetBranch;
@@ -57,6 +60,7 @@
   private final FilesCollection filesCollection;
   private final GetBranch getBranch;
   private final GetContent getContent;
+  private final CreateCommit createCommit;
   private final GetReflog getReflog;
   private final String ref;
   private final ProjectResource project;
@@ -72,6 +76,7 @@
       FilesCollection filesCollection,
       GetBranch getBranch,
       GetContent getContent,
+      CreateCommit createCommit,
       GetReflog getReflog,
       GetBranchValidationOptions getBranchValidationOptions,
       SuggestBranchReviewers suggestReviewers,
@@ -84,6 +89,7 @@
     this.getBranchValidationOptions = getBranchValidationOptions;
     this.getBranch = getBranch;
     this.getContent = getContent;
+    this.createCommit = createCommit;
     this.getReflog = getReflog;
     this.project = project;
     this.suggestReviewers = suggestReviewers;
@@ -163,6 +169,15 @@
   }
 
   @Override
+  public CommitInfo createCommit(CreateCommitInput input) throws RestApiException {
+    try {
+      return createCommit.apply(resource(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot commit files", e);
+    }
+  }
+
+  @Override
   public List<ReflogEntryInfo> reflog() throws RestApiException {
     try {
       return getReflog.apply(resource()).value();
diff --git a/java/com/google/gerrit/server/restapi/project/BranchCommitBuilder.java b/java/com/google/gerrit/server/restapi/project/BranchCommitBuilder.java
new file mode 100644
index 0000000..082d448
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/BranchCommitBuilder.java
@@ -0,0 +1,361 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput.FileChange;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.patch.DiffOperationsForCommitValidation;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.RefValidationHelper;
+import com.google.gerrit.server.update.RepoView;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * Applies a {@link CreateCommitInput} (a set of file writes/deletes/renames) directly to a branch
+ * as a single commit. Backs the {@link CreateCommit} REST view.
+ *
+ * <p>The reusable {@link CreateCommitInput}-to-{@link TreeModification} conversion lives in {@link
+ * CommitFileModifications}; this class owns the branch ref update, reusing {@link
+ * ChangeEditModifier#createNewTree} for the tree and {@link CommitUtil} for the commit.
+ */
+@Singleton
+class BranchCommitBuilder {
+  private final GitRepositoryManager repoManager;
+  private final Provider<IdentifiedUser> identifiedUser;
+  private final Provider<PersonIdent> serverIdent;
+  private final PermissionBackend permissionBackend;
+  private final GitReferenceUpdated referenceUpdated;
+  private final RefValidationHelper refUpdateValidator;
+  private final CommitValidators.Factory commitValidatorsFactory;
+  private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory;
+
+  @Inject
+  BranchCommitBuilder(
+      GitRepositoryManager repoManager,
+      Provider<IdentifiedUser> identifiedUser,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      PermissionBackend permissionBackend,
+      GitReferenceUpdated referenceUpdated,
+      RefValidationHelper.Factory refValidationHelperFactory,
+      CommitValidators.Factory commitValidatorsFactory,
+      DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory) {
+    this.repoManager = repoManager;
+    this.identifiedUser = identifiedUser;
+    this.serverIdent = serverIdent;
+    this.permissionBackend = permissionBackend;
+    this.referenceUpdated = referenceUpdated;
+    this.refUpdateValidator = refValidationHelperFactory.create(ReceiveCommand.Type.UPDATE);
+    this.commitValidatorsFactory = commitValidatorsFactory;
+    this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
+  }
+
+  /** Commits the file operations directly to the branch. Requires {@link RefPermission#UPDATE}. */
+  CommitInfo createCommit(BranchResource rsrc, CreateCommitInput input)
+      throws RestApiException, PermissionBackendException, IOException {
+    requireInput(input);
+    BranchNameKey branch = rsrc.getBranchKey();
+    checkWritableBranch(rsrc, branch);
+    permissionBackend
+        .currentUser()
+        .project(branch.project())
+        .ref(branch.branch())
+        .check(RefPermission.UPDATE);
+
+    String message = commitMessage(input);
+    ImmutableList<TreeModification> modifications = CommitFileModifications.fromInput(input);
+    ImmutableListMultimap<String, String> validationOptions =
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions);
+
+    try (Repository repo = repoManager.openRepository(branch.project());
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      Ref ref = requireBranchRef(repo, branch);
+      ObjectId expectedOld = resolveExpectedOldObjectId(input, ref, branch);
+      RevCommit base = rw.parseCommit(ref.getObjectId());
+      requireSourcePathsExist(reader, base, input);
+      ObjectId treeId = buildTree(repo, base, modifications);
+      ObjectId newCommitId = insertCommit(oi, base, treeId, message);
+      validateCommit(rsrc, repo, rw, oi, ref.getObjectId(), newCommitId, validationOptions);
+
+      try (RefUpdateContext refCtx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+        RefUpdate u = repo.updateRef(branch.branch());
+        u.setExpectedOldObjectId(expectedOld);
+        u.setNewObjectId(newCommitId);
+        u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
+        u.setRefLogMessage("commit files via REST", false);
+        refUpdateValidator.validateRefOperation(
+            branch.project().get(), identifiedUser.get(), u, validationOptions);
+        RefUpdate.Result result = u.update(rw);
+        switch (result) {
+          case FAST_FORWARD:
+          case NEW:
+          case NO_CHANGE:
+            referenceUpdated.fire(
+                branch.project(), u, ReceiveCommand.Type.UPDATE, identifiedUser.get().state());
+            break;
+          case LOCK_FAILURE:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+            throw new ResourceConflictException(
+                "branch \""
+                    + branch.branch()
+                    + "\" changed concurrently or base_revision is stale");
+          case FORCED:
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case RENAMED:
+          default:
+            throw new IOException("Failed to update " + branch.branch() + ": " + result.name());
+        }
+        return CommitUtil.toCommitInfo(rw.parseCommit(newCommitId), rw);
+      }
+    }
+  }
+
+  /**
+   * Inserts a commit with {@code treeId} on top of {@code base}. Per Gerrit convention for
+   * server-created commits, both the author and the committer are the calling user (full name and
+   * preferred email, or a generic {@code username@host} identity if no preferred email is set),
+   * consistent with a change edit publish. Author and committer share the server's timestamp and
+   * time zone, i.e. the server time at which the commit is created; the identities cannot be
+   * overridden via {@link CreateCommitInput}.
+   */
+  private ObjectId insertCommit(ObjectInserter oi, RevCommit base, ObjectId treeId, String message)
+      throws IOException {
+    PersonIdent committer = identifiedUser.get().newCommitterIdent(serverIdent.get());
+    ObjectId commitId =
+        CommitUtil.createCommitWithTree(
+            oi, committer, committer, ImmutableList.of(base), message, treeId);
+    oi.flush();
+    return commitId;
+  }
+
+  /**
+   * Runs Gerrit's commit validators on the new commit, the same validation applied to
+   * server-created commits (e.g. the create-a-change path). This ensures the direct-commit endpoint
+   * does not bypass commit-content policy (file-count limits, config validation, plugin
+   * commit-validation listeners, etc.). Change-Id enforcement does not apply here because the
+   * target is a branch ref, not a magic/change ref.
+   */
+  private void validateCommit(
+      BranchResource rsrc,
+      Repository repo,
+      RevWalk rw,
+      ObjectInserter oi,
+      ObjectId oldId,
+      ObjectId newCommitId,
+      ImmutableListMultimap<String, String> validationOptions)
+      throws ResourceConflictException, IOException {
+    BranchNameKey branch = rsrc.getBranchKey();
+    ReceiveCommand cmd = new ReceiveCommand(oldId, newCommitId, branch.branch());
+    try (RepoView repoView = new RepoView(repo, rw, oi);
+        CommitReceivedEvent event =
+            new CommitReceivedEvent(
+                cmd,
+                rsrc.getProjectState().getProject(),
+                branch.branch(),
+                validationOptions,
+                repo.getConfig(),
+                rw.getObjectReader(),
+                newCommitId,
+                identifiedUser.get(),
+                /* cherryPickOf= */ null,
+                diffOperationsForCommitValidationFactory.create(repoView, oi))) {
+      commitValidatorsFactory
+          .forGerritCommits(
+              permissionBackend.currentUser().project(branch.project()),
+              branch,
+              identifiedUser.get(),
+              rw,
+              /* change= */ null)
+          .validate(event);
+    } catch (CommitValidationException e) {
+      throw new ResourceConflictException(e.getFullMessage());
+    }
+  }
+
+  private static ObjectId buildTree(
+      Repository repo, RevCommit base, List<TreeModification> modifications)
+      throws BadRequestException, IOException {
+    try {
+      return ChangeEditModifier.createNewTree(repo, base, modifications);
+    } catch (InvalidChangeOperationException e) {
+      // Raised when the result tree is identical to the base tree (no effective change).
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+
+  private String commitMessage(CreateCommitInput input) throws BadRequestException {
+    String message = input.commitMessage == null ? "" : input.commitMessage.trim();
+    if (message.isEmpty()) {
+      throw new BadRequestException("commit message must be non-empty");
+    }
+    if (!message.endsWith("\n")) {
+      message = message + "\n";
+    }
+    return message;
+  }
+
+  /**
+   * Rejects operations whose source path is absent from the base tree. A delete of a missing path
+   * or a rename from a missing source would otherwise be silently dropped (see {@link
+   * com.google.gerrit.server.edit.tree.DeleteFileModification} / {@link
+   * com.google.gerrit.server.edit.tree.RenameFileModification}) while the surrounding commit still
+   * succeeds. This needs the base tree, so it runs here rather than in {@link
+   * CommitFileModifications}.
+   */
+  private static void requireSourcePathsExist(
+      ObjectReader reader, RevCommit base, CreateCommitInput input)
+      throws BadRequestException, IOException {
+    for (Map.Entry<String, FileChange> entry : input.files.entrySet()) {
+      FileChange change = entry.getValue();
+      if (change.delete) {
+        requireFileExists(reader, base, entry.getKey());
+      } else if (change.renameFrom != null) {
+        requireFileExists(reader, base, change.renameFrom);
+      }
+    }
+  }
+
+  /**
+   * Rejects {@code path} unless it resolves to an existing file (blob), not a missing path or a
+   * directory.
+   */
+  private static void requireFileExists(ObjectReader reader, RevCommit base, String path)
+      throws BadRequestException, IOException {
+    try (TreeWalk tw = TreeWalk.forPath(reader, path, base.getTree())) {
+      if (tw == null) {
+        throw new BadRequestException("path does not exist: " + path);
+      }
+      if (tw.getFileMode(0) == FileMode.TREE) {
+        throw new BadRequestException("path is a directory, not a file: " + path);
+      }
+    }
+  }
+
+  private ObjectId parseBaseRevision(String baseRevision) throws BadRequestException {
+    if (!ObjectId.isId(baseRevision)) {
+      throw new BadRequestException("base_revision must be a full 40-character SHA-1");
+    }
+    return ObjectId.fromString(baseRevision);
+  }
+
+  /** Loads the target branch ref, rejecting with a 409 if it is missing. */
+  private Ref requireBranchRef(Repository repo, BranchNameKey branch)
+      throws ResourceConflictException, IOException {
+    Ref ref = repo.exactRef(branch.branch());
+    if (ref == null || ref.getObjectId() == null) {
+      throw new ResourceConflictException("branch \"" + branch.branch() + "\" does not exist");
+    }
+    return ref;
+  }
+
+  /**
+   * Resolves the expected old object id: the caller-provided {@code base_revision} when set,
+   * otherwise the current branch tip.
+   *
+   * <p>If {@code base_revision} is set, it must match the current branch tip. The final ref update
+   * still performs the same compare-and-swap check to protect against races.
+   */
+  private ObjectId resolveExpectedOldObjectId(
+      CreateCommitInput input, Ref ref, BranchNameKey branch)
+      throws BadRequestException, ResourceConflictException {
+    ObjectId currentTip = ref.getObjectId();
+    if (input.baseRevision == null) {
+      return currentTip;
+    }
+    ObjectId expectedOld = parseBaseRevision(input.baseRevision);
+    if (!expectedOld.equals(currentTip)) {
+      throw new ResourceConflictException(
+          "branch \"" + branch.branch() + "\" changed concurrently or base_revision is stale");
+    }
+    return expectedOld;
+  }
+
+  private static void requireInput(@Nullable CreateCommitInput input) throws BadRequestException {
+    if (input == null) {
+      throw new BadRequestException("input is required");
+    }
+  }
+
+  /**
+   * Rejects branches this endpoint must not write to: {@code HEAD} (a symbolic ref, not an ordinary
+   * branch), read-only projects, Gerrit-internal refs, tags, and the {@code refs/meta/*} namespace
+   * (project config, schema version, dashboards, etc.).
+   */
+  private void checkWritableBranch(BranchResource rsrc, BranchNameKey branch)
+      throws MethodNotAllowedException, ResourceConflictException, AuthException {
+    if (RefNames.HEAD.equals(branch.branch())) {
+      throw new MethodNotAllowedException("not allowed to write to HEAD");
+    }
+    if (!rsrc.getProjectState().statePermitsWrite()) {
+      throw new ResourceConflictException("project state does not permit write");
+    }
+    if (RefNames.isGerritRef(branch.branch())
+        || branch.branch().startsWith(RefNames.REFS_TAGS)
+        || branch.branch().startsWith(RefNames.REFS_META)) {
+      throw new AuthException("not allowed to write to " + branch.branch() + " via this endpoint");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CommitFileModifications.java b/java/com/google/gerrit/server/restapi/project/CommitFileModifications.java
new file mode 100644
index 0000000..7d4af46
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CommitFileModifications.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput.FileChange;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.DeleteFileModification;
+import com.google.gerrit.server.edit.tree.RenameFileModification;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.util.Base64;
+
+/**
+ * Converts the file operations of a {@link CreateCommitInput} into {@link TreeModification}s.
+ *
+ * <p>This is the reusable request-to-tree-modification step shared between the direct branch-commit
+ * path and any future review path: it validates the caller-supplied input and builds the {@link
+ * TreeModification}s that {@link com.google.gerrit.server.edit.ChangeEditModifier#createNewTree}
+ * (or any other {@code TreeCreator} caller) applies. Keeping it separate lets {@link
+ * BranchCommitBuilder} focus on the branch ref update.
+ */
+final class CommitFileModifications {
+  private CommitFileModifications() {}
+
+  /**
+   * Translates the requested file operations into {@link TreeModification}s while validating the
+   * caller-supplied input, so that malformed requests fail with {@code 400 Bad Request} rather than
+   * leaking through as a server error.
+   */
+  static ImmutableList<TreeModification> fromInput(CreateCommitInput input)
+      throws BadRequestException {
+    if (input.files == null || input.files.isEmpty()) {
+      throw new BadRequestException("files is required");
+    }
+    ImmutableList.Builder<TreeModification> modifications =
+        ImmutableList.builderWithExpectedSize(input.files.size());
+    // Two operations that touch the same path (including a rename's source path) cannot be applied
+    // together; detect that here instead of letting TreeCreator throw an IllegalStateException that
+    // would surface as a 500.
+    Set<String> touchedPaths = new HashSet<>();
+    for (Map.Entry<String, FileChange> entry : input.files.entrySet()) {
+      String path = entry.getKey();
+      if (Strings.isNullOrEmpty(path)) {
+        throw new BadRequestException("file path must not be empty");
+      }
+      FileChange change = entry.getValue();
+      if (change == null) {
+        throw new BadRequestException("no operation given for " + path);
+      }
+      int ops =
+          (change.content != null ? 1 : 0)
+              + (change.delete ? 1 : 0)
+              + (change.renameFrom != null ? 1 : 0);
+      if (ops != 1) {
+        throw new BadRequestException(
+            "exactly one of content, delete, or rename_from is required for " + path);
+      }
+      if (change.fileMode != null && change.content == null) {
+        throw new BadRequestException("file_mode is only valid with content for " + path);
+      }
+      // Gitlink entries would reinterpret `content` as a 40-character SHA-1 rather than file bytes;
+      // that second meaning is out of scope for this endpoint.
+      if (change.fileMode != null && change.fileMode == 160000) {
+        throw new BadRequestException("file_mode 160000 (gitlink) is not supported for " + path);
+      }
+      TreeModification modification;
+      if (change.delete) {
+        modification = new DeleteFileModification(path);
+      } else if (change.renameFrom != null) {
+        if (change.renameFrom.isEmpty()) {
+          throw new BadRequestException("rename_from must not be empty for " + path);
+        }
+        if (change.renameFrom.equals(path)) {
+          throw new BadRequestException("rename_from must differ from the target path " + path);
+        }
+        modification = new RenameFileModification(change.renameFrom, path);
+      } else {
+        modification =
+            new ChangeFileContentModification(
+                path,
+                RawInputUtil.create(decodeBase64(change.content, path)),
+                octalToBits(change.fileMode));
+      }
+      for (String touched : modification.getFilePaths()) {
+        if (!touchedPaths.add(touched)) {
+          throw new BadRequestException("multiple operations affect the same path: " + touched);
+        }
+      }
+      modifications.add(modification);
+    }
+    return modifications.build();
+  }
+
+  private static byte[] decodeBase64(String content, String path) throws BadRequestException {
+    try {
+      return Base64.decode(content);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("content for " + path + " is not valid base64", e);
+    }
+  }
+
+  @Nullable
+  private static Integer octalToBits(@Nullable Integer octalFileMode) throws BadRequestException {
+    if (octalFileMode == null) {
+      return null;
+    }
+    try {
+      return Integer.parseInt(Integer.toString(octalFileMode), 8);
+    } catch (NumberFormatException e) {
+      throw new BadRequestException("invalid file_mode: " + octalFileMode, e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateCommit.java b/java/com/google/gerrit/server/restapi/project/CreateCommit.java
new file mode 100644
index 0000000..86db1dd
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateCommit.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2026 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.restapi.project;
+
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
+import com.google.gerrit.extensions.common.CommitInfo;
+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.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.BranchResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+/**
+ * Commits a set of file operations (create/update, delete, rename) directly to a branch as a single
+ * commit. Requires push access. Delegates to {@link BranchCommitBuilder}.
+ */
+@Singleton
+public class CreateCommit implements RestModifyView<BranchResource, CreateCommitInput> {
+  private final BranchCommitBuilder branchCommitBuilder;
+
+  @Inject
+  CreateCommit(BranchCommitBuilder branchCommitBuilder) {
+    this.branchCommitBuilder = branchCommitBuilder;
+  }
+
+  @Override
+  public Response<CommitInfo> apply(BranchResource rsrc, CreateCommitInput input)
+      throws RestApiException, PermissionBackendException, IOException {
+    return Response.ok(branchCommitBuilder.createCommit(rsrc, input));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index a3739c1..8984217 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -64,6 +64,8 @@
     child(BRANCH_KIND, "files").to(FilesCollection.class);
     get(FILE_KIND, "content").to(GetContent.class);
 
+    post(BRANCH_KIND, "commit").to(CreateCommit.class);
+
     get(BRANCH_KIND, "mergeable").to(CheckMergeability.class);
     get(BRANCH_KIND, "reflog").to(GetReflog.class);
     get(BRANCH_KIND, "suggest_reviewers").to(SuggestBranchReviewers.class);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateCommitIT.java
new file mode 100644
index 0000000..fb7644a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateCommitIT.java
@@ -0,0 +1,557 @@
+// Copyright (C) 2026 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.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput;
+import com.google.gerrit.extensions.api.projects.CreateCommitInput.FileChange;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.events.RefReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.RefOperationValidationListener;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CreateCommitIT extends AbstractDaemonTest {
+  private static final String BRANCH = "master";
+
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Before
+  public void setUp() throws Exception {
+    // Seed master with a submitted file (PushOneCommit.FILE_NAME / FILE_CONTENT).
+    PushOneCommit.Result change = createChange();
+    approve(change.getChangeId());
+    revision(change).submit();
+  }
+
+  @Test
+  public void directCreateNewFile() throws Exception {
+    RestResponse r =
+        adminRestSession.post(commitUrl(), write("Add new file", "new/file.txt", "hello"));
+    r.assertOK();
+    CommitInfo commit = newGson().fromJson(r.getReader(), CommitInfo.class);
+    assertThat(commit.commit).isNotNull();
+    assertThat(readFile("new/file.txt")).isEqualTo("hello");
+  }
+
+  @Test
+  public void directUpdateExistingFile() throws Exception {
+    RestResponse r =
+        adminRestSession.post(
+            commitUrl(), write("Update", PushOneCommit.FILE_NAME, "updated body"));
+    r.assertOK();
+    assertThat(readFile(PushOneCommit.FILE_NAME)).isEqualTo("updated body");
+  }
+
+  @Test
+  public void directMultiFileWriteAndDelete() throws Exception {
+    // First add a file we will later delete, plus the existing seeded file.
+    adminRestSession.post(commitUrl(), write("Add doomed", "doomed.txt", "bye")).assertOK();
+
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Write one, delete one";
+    input.files = new HashMap<>();
+    input.files.put("kept.txt", contentChange("kept"));
+    input.files.put("doomed.txt", deleteChange());
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    r.assertOK();
+
+    assertThat(readFile("kept.txt")).isEqualTo("kept");
+    RestResponse doomed =
+        adminRestSession.get(
+            String.format(
+                "/projects/%s/branches/%s/files/doomed.txt/content", project.get(), BRANCH));
+    doomed.assertNotFound();
+  }
+
+  @Test
+  public void directRenameFile() throws Exception {
+    // Seed a file, then rename it in a single commit.
+    adminRestSession.post(commitUrl(), write("Add original", "original.txt", "body")).assertOK();
+
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename original.txt to renamed.txt";
+    input.files = new HashMap<>();
+    input.files.put("renamed.txt", renameChange("original.txt"));
+    adminRestSession.post(commitUrl(), input).assertOK();
+
+    assertThat(readFile("renamed.txt")).isEqualTo("body");
+    RestResponse original =
+        adminRestSession.get(
+            String.format(
+                "/projects/%s/branches/%s/files/original.txt/content", project.get(), BRANCH));
+    original.assertNotFound();
+  }
+
+  @Test
+  public void directWithMatchingBaseRevisionSucceeds() throws Exception {
+    CreateCommitInput input = write("Guarded", "guarded.txt", "ok");
+    input.baseRevision = branchTip();
+    adminRestSession.post(commitUrl(), input).assertOK();
+  }
+
+  @Test
+  public void directWithStaleBaseRevisionIsRejected() throws Exception {
+    String stale = branchTip();
+    // Advance the branch so `stale` is no longer the tip.
+    adminRestSession.post(commitUrl(), write("Advance", "advance.txt", "x")).assertOK();
+
+    CreateCommitInput input = write("Stale", "stale.txt", "y");
+    input.baseRevision = stale;
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(409);
+  }
+
+  @Test
+  public void directNoOpIsRejected() throws Exception {
+    // Writing the existing content unchanged produces no tree change.
+    RestResponse r =
+        adminRestSession.post(
+            commitUrl(), write("No-op", PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT));
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directWithoutPushPermissionIsForbidden() throws Exception {
+    RestResponse r = userRestSession.post(commitUrl(), write("Nope", "x.txt", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(403);
+  }
+
+  @Test
+  public void directWithNonexistentBaseRevisionIsRejected() throws Exception {
+    CreateCommitInput input = write("Bad base", "bad.txt", "y");
+    input.baseRevision = "0123456789012345678901234567890123456789";
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(409);
+  }
+
+  @Test
+  public void directWithMalformedBaseRevisionIsRejected() throws Exception {
+    CreateCommitInput input = write("Bad base", "bad.txt", "y");
+    input.baseRevision = "deadbeef";
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directCommitInvokesCommitValidators() throws Exception {
+    // A registered plugin commit validator must see the direct commit, so the endpoint
+    // cannot be used to bypass commit-validation policy.
+    TestCommitValidationListener listener = new TestCommitValidationListener();
+    try (Registration unused = extensionRegistry.newRegistration().add(listener)) {
+      adminRestSession.post(commitUrl(), write("Validated", "validated.txt", "x")).assertOK();
+      assertThat(listener.receiveEvent).isNotNull();
+      assertThat(listener.receiveEvent.refName).isEqualTo("refs/heads/" + BRANCH);
+    }
+  }
+
+  @Test
+  public void directCommitRejectedByCommitValidator() throws Exception {
+    CommitValidationListener rejecting =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            throw new CommitValidationException("blocked by test validator");
+          }
+        };
+    try (Registration unused = extensionRegistry.newRegistration().add(rejecting)) {
+      RestResponse r = adminRestSession.post(commitUrl(), write("Nope", "blocked.txt", "x"));
+      assertThat(r.getStatusCode()).isEqualTo(409);
+    }
+  }
+
+  @Test
+  public void directCollidingPathsIsRejected() throws Exception {
+    // Seed a file, then rename it while also writing the same source path: both touch src.txt.
+    adminRestSession.post(commitUrl(), write("Add src", "src.txt", "body")).assertOK();
+
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Collide";
+    input.files = new HashMap<>();
+    input.files.put("dst.txt", renameChange("src.txt"));
+    input.files.put("src.txt", contentChange("rewritten"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directEmptyRenameFromIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Bad rename";
+    input.files = new HashMap<>();
+    input.files.put("dst.txt", renameChange(""));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directRenameToSamePathIsRejected() throws Exception {
+    adminRestSession.post(commitUrl(), write("Add self", "self.txt", "body")).assertOK();
+
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename to self";
+    input.files = new HashMap<>();
+    input.files.put("self.txt", renameChange("self.txt"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directBlankCommitMessageIsRejected() throws Exception {
+    RestResponse r = adminRestSession.post(commitUrl(), write("   ", "blankmsg.txt", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directMissingCommitMessageIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.files = new HashMap<>();
+    input.files.put("nomsg.txt", contentChange("x"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directNullBodyIsRejected() throws Exception {
+    RestResponse r =
+        adminRestSession.postRaw(
+            commitUrl(), RawInputUtil.create("null".getBytes(UTF_8), "application/json"));
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directUnsupportedFileModeIsRejected() throws Exception {
+    // 644 is a well-formed octal value but not one of the git file modes Gerrit supports; it is
+    // rejected by the shared downstream validation (Patch.FileMode), not a local list.
+    FileChange unsupported = contentChange("x");
+    unsupported.fileMode = 644;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Unsupported mode";
+    input.files = new HashMap<>();
+    input.files.put("unsupported.txt", unsupported);
+    assertThat(adminRestSession.post(commitUrl(), input).getStatusCode()).isEqualTo(400);
+
+    // A value with non-octal digits is rejected at the boundary (400) rather than causing a 500.
+    FileChange nonOctal = contentChange("x");
+    nonOctal.fileMode = 8;
+    CreateCommitInput input2 = new CreateCommitInput();
+    input2.commitMessage = "Non-octal mode";
+    input2.files = new HashMap<>();
+    input2.files.put("nonoctal.txt", nonOctal);
+    assertThat(adminRestSession.post(commitUrl(), input2).getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directExecutableFileModeSucceeds() throws Exception {
+    FileChange executable = contentChange("#!/bin/sh\n");
+    executable.fileMode = 100755;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Executable";
+    input.files = new HashMap<>();
+    input.files.put("run.sh", executable);
+    adminRestSession.post(commitUrl(), input).assertOK();
+    assertThat(readFile("run.sh")).isEqualTo("#!/bin/sh\n");
+  }
+
+  @Test
+  public void directWriteToRefsMetaConfigIsForbidden() throws Exception {
+    String url =
+        String.format("/projects/%s/branches/%s/commit", project.get(), "refs%2Fmeta%2Fconfig");
+    RestResponse r = adminRestSession.post(url, write("Nope", "project.config", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(403);
+  }
+
+  @Test
+  public void directWriteToHeadIsForbidden() throws Exception {
+    String url = String.format("/projects/%s/branches/HEAD/commit", project.get());
+    RestResponse r = adminRestSession.post(url, write("Nope", "head.txt", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(405);
+  }
+
+  @Test
+  public void directMissingFilesIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "No files";
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directEmptyFilesIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Empty files";
+    input.files = new HashMap<>();
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directInvalidBase64ContentIsRejected() throws Exception {
+    FileChange fc = new FileChange();
+    fc.content = "@@@@"; // not valid base64
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Bad base64";
+    input.files = new HashMap<>();
+    input.files.put("bad.txt", fc);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directMultipleOperationsOnEntryIsRejected() throws Exception {
+    FileChange fc = contentChange("x");
+    fc.delete = true; // content + delete on one entry
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Ambiguous";
+    input.files = new HashMap<>();
+    input.files.put("ambiguous.txt", fc);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directEmptyPathIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Empty path";
+    input.files = new HashMap<>();
+    input.files.put("", contentChange("x"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directWriteToReadOnlyProjectIsRejected() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateProject(p -> p.setState(ProjectState.READ_ONLY));
+      u.save();
+    }
+    RestResponse r = adminRestSession.post(commitUrl(), write("Read only", "ro.txt", "x"));
+    assertThat(r.getStatusCode()).isEqualTo(409);
+  }
+
+  @Test
+  public void directCommitRejectedByRefOperationValidator() throws Exception {
+    TestRefOperationValidationListener listener = new TestRefOperationValidationListener();
+    listener.doReject = true;
+    try (Registration unused = extensionRegistry.newRegistration().add(listener)) {
+      RestResponse r = adminRestSession.post(commitUrl(), write("Nope", "refblocked.txt", "x"));
+      assertThat(r.getStatusCode()).isEqualTo(409);
+    }
+  }
+
+  @Test
+  public void createCommitViaJavaApi() throws Exception {
+    CreateCommitInput input = write("Via Java API", "api.txt", "x");
+    CommitInfo commit = gApi.projects().name(project.get()).branch(BRANCH).createCommit(input);
+    assertThat(commit.subject).isEqualTo("Via Java API");
+    assertThat(branchTip()).isEqualTo(commit.commit);
+  }
+
+  @Test
+  public void validationOptionsReachCommitValidator() throws Exception {
+    TestCommitValidationListener listener = new TestCommitValidationListener();
+    try (Registration unused = extensionRegistry.newRegistration().add(listener)) {
+      CreateCommitInput input = write("With options", "commitopt.txt", "x");
+      input.validationOptions = ImmutableMap.of("key", "value");
+      adminRestSession.post(commitUrl(), input).assertOK();
+      assertThat(listener.receiveEvent.pushOptions).containsExactly("key", "value");
+    }
+  }
+
+  @Test
+  public void validationOptionsReachRefOperationValidator() throws Exception {
+    TestRefOperationValidationListener listener = new TestRefOperationValidationListener();
+    try (Registration unused = extensionRegistry.newRegistration().add(listener)) {
+      CreateCommitInput input = write("With options", "refopt.txt", "x");
+      input.validationOptions = ImmutableMap.of("key", "value");
+      adminRestSession.post(commitUrl(), input).assertOK();
+      assertThat(listener.refReceivedEvent.pushOptions).containsExactly("key", "value");
+    }
+  }
+
+  @Test
+  public void directDeleteMissingPathInMixedBatchIsRejected() throws Exception {
+    // A real write plus a delete of a path that does not exist: the delete would otherwise be
+    // silently dropped while the write succeeds.
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Delete missing";
+    input.files = new HashMap<>();
+    input.files.put("real.txt", contentChange("real"));
+    input.files.put("ghost.txt", deleteChange());
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directRenameMissingSourceInMixedBatchIsRejected() throws Exception {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename missing";
+    input.files = new HashMap<>();
+    input.files.put("kept.txt", contentChange("kept"));
+    input.files.put("moved.txt", renameChange("ghost-src.txt"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directRenameFromDirectoryIsRejected() throws Exception {
+    // A directory source would produce a malformed tree entry (500) if it reached the rename
+    // modification; it must be rejected up front with a 400.
+    adminRestSession.post(commitUrl(), write("Add nested", "dir/a.txt", "body")).assertOK();
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename dir";
+    input.files = new HashMap<>();
+    input.files.put("moved", renameChange("dir"));
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directDeleteDirectoryIsRejected() throws Exception {
+    adminRestSession.post(commitUrl(), write("Add nested", "ddir/a.txt", "body")).assertOK();
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Delete dir";
+    input.files = new HashMap<>();
+    input.files.put("ddir", deleteChange());
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directFileModeOnDeleteIsRejected() throws Exception {
+    FileChange fc = deleteChange();
+    fc.fileMode = 100644;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Delete with mode";
+    input.files = new HashMap<>();
+    input.files.put(PushOneCommit.FILE_NAME, fc);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directFileModeOnRenameIsRejected() throws Exception {
+    adminRestSession.post(commitUrl(), write("Add rm src", "rm-src.txt", "body")).assertOK();
+    FileChange fc = renameChange("rm-src.txt");
+    fc.fileMode = 100644;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Rename with mode";
+    input.files = new HashMap<>();
+    input.files.put("rm-dst.txt", fc);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  @Test
+  public void directSymlinkFileModeSucceeds() throws Exception {
+    FileChange symlink = contentChange("target/path.txt");
+    symlink.fileMode = 120000;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Symlink";
+    input.files = new HashMap<>();
+    input.files.put("link.txt", symlink);
+    adminRestSession.post(commitUrl(), input).assertOK();
+    assertThat(readFile("link.txt")).isEqualTo("target/path.txt");
+  }
+
+  @Test
+  public void directGitlinkFileModeIsRejected() throws Exception {
+    FileChange gitlink = contentChange("0123456789012345678901234567890123456789");
+    gitlink.fileMode = 160000;
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = "Gitlink";
+    input.files = new HashMap<>();
+    input.files.put("sub", gitlink);
+    RestResponse r = adminRestSession.post(commitUrl(), input);
+    assertThat(r.getStatusCode()).isEqualTo(400);
+  }
+
+  private String commitUrl() {
+    return String.format("/projects/%s/branches/%s/commit", project.get(), BRANCH);
+  }
+
+  private static FileChange contentChange(String content) {
+    FileChange fc = new FileChange();
+    fc.content = Base64.getEncoder().encodeToString(content.getBytes(UTF_8));
+    return fc;
+  }
+
+  private static FileChange deleteChange() {
+    FileChange fc = new FileChange();
+    fc.delete = true;
+    return fc;
+  }
+
+  private static FileChange renameChange(String from) {
+    FileChange fc = new FileChange();
+    fc.renameFrom = from;
+    return fc;
+  }
+
+  private static CreateCommitInput write(String message, String path, String content) {
+    CreateCommitInput input = new CreateCommitInput();
+    input.commitMessage = message;
+    input.files = new HashMap<>();
+    input.files.put(path, contentChange(content));
+    return input;
+  }
+
+  private String readFile(String path) throws Exception {
+    return gApi.projects().name(project.get()).branch(BRANCH).file(path).asString();
+  }
+
+  private String branchTip() throws Exception {
+    return gApi.projects().name(project.get()).branch(BRANCH).get().revision;
+  }
+
+  private static class TestRefOperationValidationListener
+      implements RefOperationValidationListener {
+    boolean doReject;
+    RefReceivedEvent refReceivedEvent;
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refReceivedEvent)
+        throws ValidationException {
+      this.refReceivedEvent = refReceivedEvent;
+      if (doReject) {
+        throw new ValidationException("rejected by test ref validator");
+      }
+      return ImmutableList.of();
+    }
+  }
+}