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();
+ }
+ }
+}