blob: 082d448e9e97e16596f4e198aa1ec29e075dc7bb [file]
// 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");
}
}
}