blob: 52db66f3b82b1a4616aa300977addb2ef27194df [file] [log] [blame]
// Copyright (C) 2014 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.edit;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.ChangeEditIdentityType;
import com.google.gerrit.extensions.api.changes.RebaseChangeEditInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.MergeConflictException;
import com.google.gerrit.extensions.restapi.RawInput;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.git.LockFailureException;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.AccountState;
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.RestoreFileModification;
import com.google.gerrit.server.edit.tree.TreeCreator;
import com.google.gerrit.server.edit.tree.TreeModification;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.MergeUtil;
import com.google.gerrit.server.index.change.ChangeIndexer;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.InvalidChangeOperationException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.CommitMessageUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.eclipse.jgit.diff.DiffAlgorithm;
import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.diff.Sequence;
import org.eclipse.jgit.dircache.DirCache;
import org.eclipse.jgit.dircache.InvalidPathException;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.NullProgressMonitor;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.MergeAlgorithm;
import org.eclipse.jgit.merge.MergeChunk;
import org.eclipse.jgit.merge.MergeResult;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.ResolveMerger;
import org.eclipse.jgit.merge.ThreeWayMerger;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.ReceiveCommand;
/**
* Utility functions to manipulate change edits.
*
* <p>This class contains methods to modify edit's content. For retrieving, publishing and deleting
* edit see {@link ChangeEditUtil}.
*
* <p>
*/
@Singleton
public class ChangeEditModifier {
private final ZoneId zoneId;
private final Provider<CurrentUser> currentUser;
private final PermissionBackend permissionBackend;
private final ChangeEditUtil changeEditUtil;
private final PatchSetUtil patchSetUtil;
private final ProjectCache projectCache;
private final NoteDbEdits noteDbEdits;
private final ChangeUtil changeUtil;
@Inject
ChangeEditModifier(
@GerritPersonIdent PersonIdent gerritIdent,
ChangeIndexer indexer,
Provider<CurrentUser> currentUser,
PermissionBackend permissionBackend,
ChangeEditUtil changeEditUtil,
PatchSetUtil patchSetUtil,
ProjectCache projectCache,
GitReferenceUpdated gitReferenceUpdated,
ChangeUtil changeUtil) {
this.currentUser = currentUser;
this.permissionBackend = permissionBackend;
this.zoneId = gerritIdent.getZoneId();
this.changeEditUtil = changeEditUtil;
this.patchSetUtil = patchSetUtil;
this.projectCache = projectCache;
noteDbEdits = new NoteDbEdits(gitReferenceUpdated, zoneId, indexer, currentUser);
this.changeUtil = changeUtil;
}
/**
* Creates a new change edit.
*
* @param repository the affected Git repository
* @param notes the {@link ChangeNotes} of the change for which the change edit should be created
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws InvalidChangeOperationException if a change edit already existed for the change
*/
public void createEdit(Repository repository, ChangeNotes notes)
throws AuthException, IOException, InvalidChangeOperationException,
PermissionBackendException, ResourceConflictException {
assertCanEdit(notes);
Optional<ChangeEdit> changeEdit = lookupChangeEdit(notes);
if (changeEdit.isPresent()) {
throw new InvalidChangeOperationException(
String.format("A change edit already exists for change %s", notes.getChangeId()));
}
PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
ObjectId patchSetCommitId = currentPatchSet.commitId();
noteDbEdits.createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.now());
}
/**
* Rebase change edit on latest patch set
*
* @param repository the affected Git repository
* @param notes the {@link ChangeNotes} of the change whose change edit should be rebased
* @param input the request input
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws InvalidChangeOperationException if a change edit doesn't exist for the specified
* change, the change edit is already based on the latest patch set, or the change represents
* the root commit
*/
public void rebaseEdit(Repository repository, ChangeNotes notes, RebaseChangeEditInput input)
throws AuthException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
assertCanEdit(notes);
Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
if (!optionalChangeEdit.isPresent()) {
throw new InvalidChangeOperationException(
String.format("No change edit exists for change %s", notes.getChangeId()));
}
ChangeEdit changeEdit = optionalChangeEdit.get();
PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
if (isBasedOn(changeEdit, currentPatchSet)) {
throw new InvalidChangeOperationException(
String.format(
"Change edit for change %s is already based on latest patch set %s",
notes.getChangeId(), currentPatchSet.id()));
}
rebase(notes.getProjectName(), repository, changeEdit, currentPatchSet, input.allowConflicts);
}
private void rebase(
Project.NameKey project,
Repository repository,
ChangeEdit changeEdit,
PatchSet currentPatchSet,
boolean allowConflicts)
throws IOException, MergeConflictException, InvalidChangeOperationException {
RevCommit currentEditCommit = changeEdit.getEditCommit();
if (currentEditCommit.getParentCount() == 0) {
throw new InvalidChangeOperationException(
"Rebase change edit against root commit not supported");
}
RevCommit basePatchSetCommit = NoteDbEdits.lookupCommit(repository, currentPatchSet.commitId());
ObjectId newTreeId = merge(repository, changeEdit, basePatchSetCommit, allowConflicts);
Instant nowTimestamp = TimeUtil.now();
String commitMessage = currentEditCommit.getFullMessage();
ObjectId newEditCommitId =
createCommit(
repository,
basePatchSetCommit,
newTreeId,
commitMessage,
currentEditCommit.getAuthorIdent(),
new PersonIdent(currentEditCommit.getCommitterIdent(), nowTimestamp));
noteDbEdits.baseEditOnDifferentPatchset(
project,
repository,
changeEdit,
currentPatchSet,
currentEditCommit,
newEditCommitId,
nowTimestamp);
}
/**
* Modifies the commit message of a change edit. If the change edit doesn't exist, a new one will
* be created based on the current patch set.
*
* @param repository the affected Git repository
* @param notes the {@link ChangeNotes} of the change whose change edit's message should be
* modified
* @param newCommitMessage the new commit message
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws InvalidChangeOperationException if the commit message is the same as before
* @throws BadRequestException if the commit message is malformed
*/
public void modifyMessage(Repository repository, ChangeNotes notes, String newCommitMessage)
throws AuthException, IOException, InvalidChangeOperationException,
PermissionBackendException, BadRequestException, ResourceConflictException {
modifyCommit(
repository,
notes,
new ModificationIntention.LatestCommit(),
CommitModification.builder().newCommitMessage(newCommitMessage).build());
}
public void modifyIdentity(
Repository repository,
ChangeNotes notes,
PersonIdent identity,
ChangeEditIdentityType identityType)
throws AuthException, IOException, InvalidChangeOperationException,
PermissionBackendException, BadRequestException, ResourceConflictException {
CommitModification.Builder cmb = CommitModification.builder();
switch (identityType) {
case AUTHOR:
cmb.newAuthor(identity);
break;
case COMMITTER:
default:
cmb.newCommitter(identity);
break;
}
modifyCommit(repository, notes, new ModificationIntention.LatestCommit(), cmb.build());
}
/**
* Modifies the contents of a file of a change edit. If the change edit doesn't exist, a new one
* will be created based on the current patch set.
*
* @param repository the affected Git repository
* @param notes the {@link ChangeNotes} of the change whose change edit should be modified
* @param filePath the path of the file whose contents should be modified
* @param newContent the new file content
* @param newGitFileMode the new file mode in octal format. {@code null} indicates no change
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
* @throws InvalidChangeOperationException if the file already had the specified content
* @throws ResourceConflictException if the project state does not permit the operation
*/
public void modifyFile(
Repository repository,
ChangeNotes notes,
String filePath,
RawInput newContent,
@Nullable Integer newGitFileMode)
throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
modifyCommit(
repository,
notes,
new ModificationIntention.LatestCommit(),
CommitModification.builder()
.addTreeModification(
new ChangeFileContentModification(filePath, newContent, newGitFileMode))
.build());
}
/**
* Deletes a file from the Git tree of a change edit. If the change edit doesn't exist, a new one
* will be created based on the current patch set.
*
* @param repository the affected Git repository
* @param notes the {@link ChangeNotes} of the change whose change edit should be modified
* @param file path of the file which should be deleted
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
* @throws InvalidChangeOperationException if the file does not exist
* @throws ResourceConflictException if the project state does not permit the operation
*/
public void deleteFile(Repository repository, ChangeNotes notes, String file)
throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
modifyCommit(
repository,
notes,
new ModificationIntention.LatestCommit(),
CommitModification.builder().addTreeModification(new DeleteFileModification(file)).build());
}
/**
* Renames a file of a change edit or moves it to another directory. If the change edit doesn't
* exist, a new one will be created based on the current patch set.
*
* @param repository the affected Git repository
* @param notes the {@link ChangeNotes} of the change whose change edit should be modified
* @param currentFilePath the current path/name of the file
* @param newFilePath the desired path/name of the file
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
* @throws InvalidChangeOperationException if the file was already renamed to the specified new
* name
* @throws ResourceConflictException if the project state does not permit the operation
*/
public void renameFile(
Repository repository, ChangeNotes notes, String currentFilePath, String newFilePath)
throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
modifyCommit(
repository,
notes,
new ModificationIntention.LatestCommit(),
CommitModification.builder()
.addTreeModification(new RenameFileModification(currentFilePath, newFilePath))
.build());
}
/**
* Restores a file of a change edit to the state it was in before the patch set on which the
* change edit is based. If the change edit doesn't exist, a new one will be created based on the
* current patch set.
*
* @param repository the affected Git repository
* @param notes the {@link ChangeNotes} of the change whose change edit should be modified
* @param file the path of the file which should be restored
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws InvalidChangeOperationException if the file was already restored
*/
public void restoreFile(Repository repository, ChangeNotes notes, String file)
throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
modifyCommit(
repository,
notes,
new ModificationIntention.LatestCommit(),
CommitModification.builder()
.addTreeModification(new RestoreFileModification(file))
.build());
}
/**
* Applies the indicated modifications to the specified patch set. If a change edit exists and is
* based on the same patch set, the modified patch set tree is merged with the change edit. If the
* change edit doesn't exist, a new one will be created.
*
* @param repository the affected Git repository
* @param notes the {@link ChangeNotes} of the change to which the patch set belongs
* @param patchSet the {@code PatchSet} which should be modified
* @param commitModification the modifications which should be applied
* @return the resulting {@code ChangeEdit}
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws InvalidChangeOperationException if the existing change edit is based on another patch
* set or no change edit exists but the specified patch set isn't the current one
* @throws MergeConflictException if the modified patch set tree can't be merged with an existing
* change edit
*/
public ChangeEdit combineWithModifiedPatchSetTree(
Repository repository,
ChangeNotes notes,
PatchSet patchSet,
CommitModification commitModification)
throws AuthException, BadRequestException, IOException, InvalidChangeOperationException,
PermissionBackendException, ResourceConflictException {
return modifyCommit(
repository, notes, new ModificationIntention.PatchsetCommit(patchSet), commitModification);
}
@CanIgnoreReturnValue
private ChangeEdit modifyCommit(
Repository repository,
ChangeNotes notes,
ModificationIntention modificationIntention,
CommitModification commitModification)
throws AuthException, BadRequestException, IOException, InvalidChangeOperationException,
PermissionBackendException, ResourceConflictException {
assertCanEdit(notes);
Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
EditBehavior editBehavior =
optionalChangeEdit
.<EditBehavior>map(changeEdit -> new ExistingEditBehavior(changeEdit, noteDbEdits))
.orElseGet(() -> new NewEditBehavior(noteDbEdits));
ModificationTarget modificationTarget =
editBehavior.getModificationTarget(notes, modificationIntention);
RevCommit commitToModify = modificationTarget.getCommit(repository);
ObjectId newTreeId =
createNewTree(repository, commitToModify, commitModification.treeModifications());
newTreeId = editBehavior.mergeTreesIfNecessary(repository, newTreeId, commitToModify);
PatchSet basePatchset = modificationTarget.getBasePatchset();
RevCommit basePatchsetCommit = NoteDbEdits.lookupCommit(repository, basePatchset.commitId());
boolean changeIdRequired =
projectCache
.get(notes.getChange().getProject())
.orElseThrow(illegalState(notes.getChange().getProject()))
.is(BooleanProjectConfig.REQUIRE_CHANGE_ID);
String currentChangeId = notes.getChange().getKey().get();
String newCommitMessage =
createNewCommitMessage(
changeIdRequired, currentChangeId, editBehavior, commitModification, commitToModify);
newCommitMessage = editBehavior.mergeCommitMessageIfNecessary(newCommitMessage, commitToModify);
Instant nowTimestamp = TimeUtil.now();
PersonIdent author = getAuthor(commitModification, commitToModify, nowTimestamp);
PersonIdent committer =
getCommitter(commitModification, commitToModify, basePatchsetCommit, nowTimestamp);
Optional<ChangeEdit> unmodifiedEdit =
editBehavior.getEditIfNoModification(
newTreeId, newCommitMessage,
author, committer);
if (unmodifiedEdit.isPresent()) {
return unmodifiedEdit.get();
}
ObjectId newEditCommit =
createCommit(
repository, basePatchsetCommit, newTreeId, newCommitMessage, author, committer);
try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
return editBehavior.updateEditInStorage(
repository, notes, basePatchset, newEditCommit, nowTimestamp);
}
}
private PersonIdent getAuthor(
CommitModification commitModification, RevCommit commitToModify, Instant timestamp) {
PersonIdent currentAuthor = commitToModify.getAuthorIdent();
if (!commitModification.newAuthor().isPresent()) {
return currentAuthor;
}
PersonIdent newAuthor = commitModification.newAuthor().get();
String newName = newAuthor.getName();
String newEmail = newAuthor.getEmailAddress();
return new PersonIdent(
newName.isEmpty() ? currentAuthor.getName() : newName,
newEmail.isEmpty() ? currentAuthor.getEmailAddress() : newEmail,
timestamp,
zoneId);
}
private PersonIdent getCommitter(
CommitModification commitModification,
RevCommit commitToModify,
RevCommit basePatchsetCommit,
Instant timestamp) {
PersonIdent currentCommitter = commitToModify.getCommitterIdent();
if (!commitModification.newCommitter().isPresent()) {
if (commitToModify.equals(basePatchsetCommit)) {
return getCommitterIdent(basePatchsetCommit, timestamp);
}
return new PersonIdent(currentCommitter, timestamp);
}
PersonIdent newCommitter = commitModification.newCommitter().get();
String newName = newCommitter.getName();
String newEmail = newCommitter.getEmailAddress();
return new PersonIdent(
newName.isEmpty() ? currentCommitter.getName() : newName,
newEmail.isEmpty() ? currentCommitter.getEmailAddress() : newEmail,
timestamp,
zoneId);
}
private void assertCanEdit(ChangeNotes notes)
throws AuthException, PermissionBackendException, ResourceConflictException {
if (!currentUser.get().isIdentifiedUser()) {
throw new AuthException("Authentication required");
}
Change c = notes.getChange();
if (!c.isNew()) {
throw new ResourceConflictException(
String.format("change %s is %s", c.getChangeId(), ChangeUtil.status(c)));
}
// Not allowed to edit if the current patch set is locked.
patchSetUtil.checkPatchSetNotLocked(notes);
boolean canEdit =
permissionBackend.currentUser().change(notes).test(ChangePermission.ADD_PATCH_SET);
canEdit &=
projectCache
.get(notes.getProjectName())
.orElseThrow(illegalState(notes.getProjectName()))
.statePermitsWrite();
if (!canEdit) {
throw new AuthException("edit not permitted");
}
}
private Optional<ChangeEdit> lookupChangeEdit(ChangeNotes notes)
throws AuthException, IOException {
return changeEditUtil.byChange(notes);
}
private PatchSet lookupCurrentPatchSet(ChangeNotes notes) {
return patchSetUtil.current(notes);
}
private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
PatchSet editBasePatchSet = changeEdit.getBasePatchSet();
return editBasePatchSet.id().equals(patchSet.id());
}
public static ObjectId createNewTree(
Repository repository, RevCommit baseCommit, List<TreeModification> treeModifications)
throws BadRequestException, IOException, InvalidChangeOperationException {
if (treeModifications.isEmpty()) {
return baseCommit.getTree();
}
ObjectId newTreeId;
try {
TreeCreator treeCreator = TreeCreator.basedOn(baseCommit);
treeCreator.addTreeModifications(treeModifications);
newTreeId = treeCreator.createNewTreeAndGetId(repository);
} catch (InvalidPathException e) {
throw new BadRequestException(e.getMessage());
}
if (ObjectId.isEqual(newTreeId, baseCommit.getTree())) {
throw new InvalidChangeOperationException("no changes were made");
}
return newTreeId;
}
private static ObjectId merge(
Repository repository,
ChangeEdit changeEdit,
RevCommit basePatchSetCommit,
boolean allowConflicts)
throws IOException, MergeConflictException {
PatchSet basePatchSet = changeEdit.getBasePatchSet();
ObjectId basePatchSetCommitId = basePatchSet.commitId();
ObjectId editCommitId = changeEdit.getEditCommit();
try (RevWalk revWalk = new RevWalk(repository);
ObjectInserter objectInserter = repository.newObjectInserter()) {
ThreeWayMerger merger = MergeStrategy.RESOLVE.newMerger(repository, true);
merger.setBase(basePatchSetCommitId);
DirCache dc = DirCache.newInCore();
if (allowConflicts && merger instanceof ResolveMerger) {
// The DirCache must be set on ResolveMerger before calling
// ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get
// populated.
((ResolveMerger) merger).setDirCache(dc);
}
boolean successful = merger.merge(basePatchSetCommit, editCommitId);
ObjectId newTreeId;
if (successful) {
newTreeId = merger.getResultTreeId();
} else {
List<String> conflicts = ImmutableList.of();
if (merger instanceof ResolveMerger) {
conflicts = ((ResolveMerger) merger).getUnmergedPaths();
}
if (!allowConflicts || !(merger instanceof ResolveMerger)) {
throw new MergeConflictException(
String.format(
"Rebasing change edit onto another patchset results in merge conflicts.\n\n"
+ "%s\n\n"
+ "Download the edit patchset and rebase manually to preserve changes.",
MergeUtil.createConflictMessage(conflicts)));
}
Map<String, MergeResult<? extends Sequence>> mergeResults =
((ResolveMerger) merger).getMergeResults();
newTreeId =
MergeUtil.mergeWithConflicts(
revWalk,
objectInserter,
dc,
"PATCH SET",
basePatchSetCommit,
"EDIT",
revWalk.parseCommit(editCommitId),
mergeResults,
/* diff3Format= */ false);
objectInserter.flush();
}
return newTreeId;
}
}
private static ObjectId mergeTrees(Repository repository, ChangeEdit changeEdit, ObjectId treeId)
throws IOException, MergeConflictException {
PatchSet basePatchSet = changeEdit.getBasePatchSet();
ObjectId basePatchSetCommitId = basePatchSet.commitId();
ObjectId editCommitId = changeEdit.getEditCommit();
ThreeWayMerger merger = MergeStrategy.RESOLVE.newMerger(repository, true);
merger.setBase(basePatchSetCommitId);
boolean successful = merger.merge(treeId, editCommitId);
if (!successful) {
throw new MergeConflictException(
"Rebasing change edit onto another patchset results in merge conflicts. Download the edit"
+ " patchset and rebase manually to preserve changes.");
}
return merger.getResultTreeId();
}
private String createNewCommitMessage(
boolean requireChangeId,
String currentChangeId,
EditBehavior editBehavior,
CommitModification commitModification,
RevCommit commitToModify)
throws InvalidChangeOperationException, BadRequestException, ResourceConflictException {
if (!commitModification.newCommitMessage().isPresent()) {
return editBehavior.getUnmodifiedCommitMessage(commitToModify);
}
String newCommitMessage =
CommitMessageUtil.checkAndSanitizeCommitMessage(
commitModification.newCommitMessage().get());
if (newCommitMessage.equals(commitToModify.getFullMessage())) {
throw new InvalidChangeOperationException(
"New commit message cannot be same as existing commit message");
}
changeUtil.ensureChangeIdIsCorrect(requireChangeId, currentChangeId, newCommitMessage);
return newCommitMessage;
}
private ObjectId createCommit(
Repository repository,
RevCommit basePatchsetCommit,
ObjectId tree,
String commitMessage,
PersonIdent author,
PersonIdent committer)
throws IOException {
try (ObjectInserter objectInserter = repository.newObjectInserter()) {
CommitBuilder builder = new CommitBuilder();
builder.setTreeId(tree);
builder.setParentIds(basePatchsetCommit.getParents());
builder.setAuthor(author);
builder.setCommitter(committer);
builder.setMessage(commitMessage);
ObjectId newCommitId = objectInserter.insert(builder);
objectInserter.flush();
return newCommitId;
}
}
private PersonIdent getCommitterIdent(RevCommit basePatchsetCommit, Instant commitTimestamp) {
IdentifiedUser user = currentUser.get().asIdentifiedUser();
return Optional.ofNullable(basePatchsetCommit.getCommitterIdent())
.map(
ident ->
user.newCommitterIdent(ident.getEmailAddress(), commitTimestamp, zoneId)
.orElseGet(() -> user.newCommitterIdent(commitTimestamp, zoneId)))
.orElseGet(() -> user.newCommitterIdent(commitTimestamp, zoneId));
}
/**
* Strategy to apply depending on the current situation regarding change edits (e.g. creating a
* new edit requires different storage modifications than updating an existing edit).
*/
private interface EditBehavior {
ModificationTarget getModificationTarget(
ChangeNotes notes, ModificationIntention targetIntention)
throws InvalidChangeOperationException;
ObjectId mergeTreesIfNecessary(
Repository repository, ObjectId newTreeId, ObjectId commitToModify)
throws IOException, MergeConflictException;
String getUnmodifiedCommitMessage(RevCommit commitToModify);
String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify)
throws MergeConflictException;
Optional<ChangeEdit> getEditIfNoModification(
ObjectId newTreeId,
String newCommitMessage,
PersonIdent newAuthor,
PersonIdent newCommitter);
ChangeEdit updateEditInStorage(
Repository repository,
ChangeNotes notes,
PatchSet basePatchSet,
ObjectId newEditCommitId,
Instant timestamp)
throws IOException;
}
private static class ExistingEditBehavior implements EditBehavior {
private final ChangeEdit changeEdit;
private final NoteDbEdits noteDbEdits;
ExistingEditBehavior(ChangeEdit changeEdit, NoteDbEdits noteDbEdits) {
this.changeEdit = changeEdit;
this.noteDbEdits = noteDbEdits;
}
@Override
public ModificationTarget getModificationTarget(
ChangeNotes notes, ModificationIntention targetIntention)
throws InvalidChangeOperationException {
ModificationTarget modificationTarget = targetIntention.getTargetWhenEditExists(changeEdit);
// It would be better to do this validation in the implementation of the REST endpoints
// before calling any write actions on ChangeEditModifier.
modificationTarget.ensureTargetMayBeModifiedDespiteExistingEdit(changeEdit);
return modificationTarget;
}
@Override
public ObjectId mergeTreesIfNecessary(
Repository repository, ObjectId newTreeId, ObjectId commitToModify)
throws IOException, MergeConflictException {
if (ObjectId.isEqual(changeEdit.getEditCommit(), commitToModify)) {
return newTreeId;
}
return mergeTrees(repository, changeEdit, newTreeId);
}
@Override
public String getUnmodifiedCommitMessage(RevCommit commitToModify) {
return changeEdit.getEditCommit().getFullMessage();
}
@Override
public String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify)
throws MergeConflictException {
if (ObjectId.isEqual(changeEdit.getEditCommit(), commitToModify)) {
return newCommitMessage;
}
String editCommitMessage = changeEdit.getEditCommit().getFullMessage();
if (editCommitMessage.equals(newCommitMessage)) {
return editCommitMessage;
}
return mergeCommitMessage(newCommitMessage, commitToModify, editCommitMessage);
}
private String mergeCommitMessage(
String newCommitMessage, RevCommit commitToModify, String editCommitMessage)
throws MergeConflictException {
MergeAlgorithm mergeAlgorithm =
new MergeAlgorithm(DiffAlgorithm.getAlgorithm(SupportedAlgorithm.MYERS));
RawText baseMessage =
new RawText(commitToModify.getFullMessage().getBytes(StandardCharsets.UTF_8));
RawText oldMessage = new RawText(editCommitMessage.getBytes(StandardCharsets.UTF_8));
RawText newMessage = new RawText(newCommitMessage.getBytes(StandardCharsets.UTF_8));
RawTextComparator textComparator = RawTextComparator.DEFAULT;
MergeResult<RawText> mergeResult =
mergeAlgorithm.merge(textComparator, baseMessage, oldMessage, newMessage);
if (mergeResult.containsConflicts()) {
throw new MergeConflictException(
"The chosen modification adjusted the commit message. However, the new commit message"
+ " could not be merged with the commit message of the existing change edit."
+ " Please manually apply the desired changes to the commit message of the change"
+ " edit.");
}
StringBuilder resultingCommitMessage = new StringBuilder();
for (MergeChunk mergeChunk : mergeResult) {
RawText mergedMessagePart = mergeResult.getSequences().get(mergeChunk.getSequenceIndex());
resultingCommitMessage.append(
mergedMessagePart.getString(mergeChunk.getBegin(), mergeChunk.getEnd(), false));
}
return resultingCommitMessage.toString();
}
@Override
public Optional<ChangeEdit> getEditIfNoModification(
ObjectId newTreeId,
String newCommitMessage,
PersonIdent newAuthor,
PersonIdent newCommitter) {
RevCommit editCommit = changeEdit.getEditCommit();
if (!ObjectId.isEqual(newTreeId, editCommit.getTree())) {
return Optional.empty();
}
if (!Objects.equals(newCommitMessage, editCommit.getFullMessage())) {
return Optional.empty();
}
if (!newAuthor.getName().equals(editCommit.getAuthorIdent().getName())
|| !newAuthor.getEmailAddress().equals(editCommit.getAuthorIdent().getEmailAddress())) {
return Optional.empty();
}
if (!newCommitter.getName().equals(editCommit.getCommitterIdent().getName())
|| !newCommitter
.getEmailAddress()
.equals(editCommit.getCommitterIdent().getEmailAddress())) {
return Optional.empty();
}
// Modifications are already contained in the change edit.
return Optional.of(changeEdit);
}
@Override
public ChangeEdit updateEditInStorage(
Repository repository,
ChangeNotes notes,
PatchSet basePatchSet,
ObjectId newEditCommitId,
Instant timestamp)
throws IOException {
return noteDbEdits.updateEdit(
notes.getProjectName(), repository, changeEdit, newEditCommitId, timestamp);
}
}
private static class NewEditBehavior implements EditBehavior {
private final NoteDbEdits noteDbEdits;
NewEditBehavior(NoteDbEdits noteDbEdits) {
this.noteDbEdits = noteDbEdits;
}
@Override
public ModificationTarget getModificationTarget(
ChangeNotes notes, ModificationIntention targetIntention)
throws InvalidChangeOperationException {
ModificationTarget modificationTarget = targetIntention.getTargetWhenNoEdit(notes);
// It would be better to do this validation in the implementation of the REST endpoints
// before calling any write actions on ChangeEditModifier.
modificationTarget.ensureNewEditMayBeBasedOnTarget(notes.getChange());
return modificationTarget;
}
@Override
public ObjectId mergeTreesIfNecessary(
Repository repository, ObjectId newTreeId, ObjectId commitToModify) {
return newTreeId;
}
@Override
public String getUnmodifiedCommitMessage(RevCommit commitToModify) {
return commitToModify.getFullMessage();
}
@Override
public String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify) {
return newCommitMessage;
}
@Override
public Optional<ChangeEdit> getEditIfNoModification(
ObjectId newTreeId,
String newCommitMessage,
PersonIdent newAuthor,
PersonIdent newCommitter) {
return Optional.empty();
}
@Override
public ChangeEdit updateEditInStorage(
Repository repository,
ChangeNotes notes,
PatchSet basePatchSet,
ObjectId newEditCommitId,
Instant timestamp)
throws IOException {
return noteDbEdits.createEdit(repository, notes, basePatchSet, newEditCommitId, timestamp);
}
}
private static class NoteDbEdits {
private final ZoneId zoneId;
private final ChangeIndexer indexer;
private final Provider<CurrentUser> currentUser;
private final GitReferenceUpdated gitReferenceUpdated;
NoteDbEdits(
GitReferenceUpdated gitReferenceUpdated,
ZoneId zoneId,
ChangeIndexer indexer,
Provider<CurrentUser> currentUser) {
this.zoneId = zoneId;
this.indexer = indexer;
this.currentUser = currentUser;
this.gitReferenceUpdated = gitReferenceUpdated;
}
@CanIgnoreReturnValue
ChangeEdit createEdit(
Repository repository,
ChangeNotes notes,
PatchSet basePatchset,
ObjectId newEditCommitId,
Instant timestamp)
throws IOException {
Change change = notes.getChange();
String editRefName = getEditRefName(change, basePatchset);
updateReference(
notes.getProjectName(),
repository,
editRefName,
ObjectId.zeroId(),
newEditCommitId,
timestamp);
reindex(notes);
RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
return new ChangeEdit(change, editRefName, newEditCommit, basePatchset);
}
private String getEditRefName(Change change, PatchSet basePatchset) {
IdentifiedUser me = currentUser.get().asIdentifiedUser();
return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchset.id());
}
private AccountState getUpdater() {
return currentUser.get().asIdentifiedUser().state();
}
ChangeEdit updateEdit(
Project.NameKey projectName,
Repository repository,
ChangeEdit changeEdit,
ObjectId newEditCommitId,
Instant timestamp)
throws IOException {
String editRefName = changeEdit.getRefName();
RevCommit currentEditCommit = changeEdit.getEditCommit();
updateReference(
projectName, repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
reindex(changeEdit.getChange());
RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
return new ChangeEdit(
changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
}
private void updateReference(
Project.NameKey projectName,
Repository repository,
String refName,
ObjectId currentObjectId,
ObjectId targetObjectId,
Instant timestamp)
throws IOException {
try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
RefUpdate ru = repository.updateRef(refName);
ru.setExpectedOldObjectId(currentObjectId);
ru.setNewObjectId(targetObjectId);
ru.setRefLogIdent(getRefLogIdent(timestamp));
ru.setRefLogMessage("inline edit (amend)", false);
ru.setForceUpdate(true);
try (RevWalk revWalk = new RevWalk(repository)) {
RefUpdate.Result res = ru.update(revWalk);
String message = "cannot update " + ru.getName() + " in " + projectName + ": " + res;
if (res == RefUpdate.Result.LOCK_FAILURE) {
throw new LockFailureException(message, ru);
}
if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
throw new IOException(message);
}
}
gitReferenceUpdated.fire(projectName, ru, getUpdater());
}
}
void baseEditOnDifferentPatchset(
Project.NameKey project,
Repository repository,
ChangeEdit changeEdit,
PatchSet currentPatchSet,
ObjectId currentEditCommit,
ObjectId newEditCommitId,
Instant nowTimestamp)
throws IOException {
String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
updateReferenceWithNameChange(
project,
repository,
changeEdit.getRefName(),
currentEditCommit,
newEditRefName,
newEditCommitId,
nowTimestamp);
reindex(changeEdit.getChange());
}
private void updateReferenceWithNameChange(
Project.NameKey projectName,
Repository repository,
String currentRefName,
ObjectId currentObjectId,
String newRefName,
ObjectId targetObjectId,
Instant timestamp)
throws IOException {
try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
batchRefUpdate.addCommand(
new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
batchRefUpdate.addCommand(
new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
batchRefUpdate.setRefLogMessage("rebase edit", false);
batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
try (RevWalk revWalk = new RevWalk(repository)) {
batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
}
for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
if (cmd.getResult() != ReceiveCommand.Result.OK) {
throw new IOException("failed: " + cmd);
}
}
gitReferenceUpdated.fire(projectName, batchRefUpdate, getUpdater());
}
}
static RevCommit lookupCommit(Repository repository, ObjectId commitId) throws IOException {
try (RevWalk revWalk = new RevWalk(repository)) {
return revWalk.parseCommit(commitId);
}
}
private PersonIdent getRefLogIdent(Instant timestamp) {
IdentifiedUser user = currentUser.get().asIdentifiedUser();
return user.newRefLogIdent(timestamp, zoneId);
}
private void reindex(Change change) {
indexer.index(change.getProject(), change.getId());
}
private void reindex(ChangeNotes notes) {
indexer.index(notes);
}
}
}