| // 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.common.base.Preconditions.checkNotNull; |
| import static com.google.common.base.Preconditions.checkState; |
| import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; |
| |
| import com.google.common.base.Strings; |
| import com.google.common.io.ByteStreams; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.common.TimeUtil; |
| import com.google.gerrit.extensions.restapi.AuthException; |
| import com.google.gerrit.extensions.restapi.RawInput; |
| import com.google.gerrit.extensions.restapi.ResourceConflictException; |
| import com.google.gerrit.reviewdb.client.Change; |
| import com.google.gerrit.reviewdb.client.PatchSet; |
| import com.google.gerrit.reviewdb.client.Project; |
| import com.google.gerrit.reviewdb.client.RefNames; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.GerritPersonIdent; |
| import com.google.gerrit.server.IdentifiedUser; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.project.InvalidChangeOperationException; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.Singleton; |
| |
| import org.eclipse.jgit.dircache.DirCache; |
| import org.eclipse.jgit.dircache.DirCacheBuilder; |
| import org.eclipse.jgit.dircache.DirCacheEditor; |
| import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath; |
| import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit; |
| import org.eclipse.jgit.dircache.DirCacheEntry; |
| import org.eclipse.jgit.lib.BatchRefUpdate; |
| import org.eclipse.jgit.lib.CommitBuilder; |
| import org.eclipse.jgit.lib.FileMode; |
| import org.eclipse.jgit.lib.NullProgressMonitor; |
| 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.merge.MergeStrategy; |
| import org.eclipse.jgit.merge.ThreeWayMerger; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| import org.eclipse.jgit.transport.ReceiveCommand; |
| import org.eclipse.jgit.treewalk.TreeWalk; |
| |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.util.Map; |
| import java.util.TimeZone; |
| |
| /** |
| * 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 static enum TreeOperation { |
| CHANGE_ENTRY, |
| DELETE_ENTRY, |
| RENAME_ENTRY, |
| RESTORE_ENTRY |
| } |
| private final TimeZone tz; |
| private final GitRepositoryManager gitManager; |
| private final Provider<CurrentUser> currentUser; |
| |
| @Inject |
| ChangeEditModifier(@GerritPersonIdent PersonIdent gerritIdent, |
| GitRepositoryManager gitManager, |
| Provider<CurrentUser> currentUser) { |
| this.gitManager = gitManager; |
| this.currentUser = currentUser; |
| this.tz = gerritIdent.getTimeZone(); |
| } |
| |
| /** |
| * Create new change edit. |
| * |
| * @param change to create change edit for |
| * @param ps patch set to create change edit on |
| * @return result |
| * @throws AuthException |
| * @throws IOException |
| * @throws ResourceConflictException When change edit already |
| * exists for the change |
| */ |
| public RefUpdate.Result createEdit(Change change, PatchSet ps) |
| throws AuthException, IOException, ResourceConflictException { |
| if (!currentUser.get().isIdentifiedUser()) { |
| throw new AuthException("Authentication required"); |
| } |
| |
| IdentifiedUser me = (IdentifiedUser) currentUser.get(); |
| String refPrefix = RefNames.refsEditPrefix(me.getAccountId(), change.getId()); |
| |
| try (Repository repo = gitManager.openRepository(change.getProject())) { |
| Map<String, Ref> refs = repo.getRefDatabase().getRefs(refPrefix); |
| if (!refs.isEmpty()) { |
| throw new ResourceConflictException("edit already exists"); |
| } |
| |
| try (RevWalk rw = new RevWalk(repo)) { |
| ObjectId revision = ObjectId.fromString(ps.getRevision().get()); |
| String editRefName = RefNames.refsEdit(me.getAccountId(), change.getId(), |
| ps.getId()); |
| return update(repo, me, editRefName, rw, ObjectId.zeroId(), revision); |
| } |
| } |
| } |
| |
| /** |
| * Rebase change edit on latest patch set |
| * |
| * @param edit change edit that contains edit to rebase |
| * @param current patch set to rebase the edit on |
| * @throws AuthException |
| * @throws ResourceConflictException thrown if rebase fails due to merge conflicts |
| * @throws InvalidChangeOperationException |
| * @throws IOException |
| */ |
| public void rebaseEdit(ChangeEdit edit, PatchSet current) |
| throws AuthException, ResourceConflictException, |
| InvalidChangeOperationException, IOException { |
| if (!currentUser.get().isIdentifiedUser()) { |
| throw new AuthException("Authentication required"); |
| } |
| |
| Change change = edit.getChange(); |
| IdentifiedUser me = (IdentifiedUser) currentUser.get(); |
| String refName = RefNames.refsEdit(me.getAccountId(), change.getId(), |
| current.getId()); |
| try (Repository repo = gitManager.openRepository(change.getProject()); |
| RevWalk rw = new RevWalk(repo); |
| ObjectInserter inserter = repo.newObjectInserter()) { |
| BatchRefUpdate ru = repo.getRefDatabase().newBatchUpdate(); |
| RevCommit editCommit = edit.getEditCommit(); |
| if (editCommit.getParentCount() == 0) { |
| throw new InvalidChangeOperationException( |
| "Rebase edit against root commit not supported"); |
| } |
| RevCommit tip = rw.parseCommit(ObjectId.fromString( |
| current.getRevision().get())); |
| ThreeWayMerger m = MergeStrategy.RESOLVE.newMerger(repo, true); |
| m.setObjectInserter(inserter); |
| m.setBase(ObjectId.fromString( |
| edit.getBasePatchSet().getRevision().get())); |
| |
| if (m.merge(tip, editCommit)) { |
| ObjectId tree = m.getResultTreeId(); |
| |
| CommitBuilder commit = new CommitBuilder(); |
| commit.setTreeId(tree); |
| for (int i = 0; i < tip.getParentCount(); i++) { |
| commit.addParentId(tip.getParent(i)); |
| } |
| commit.setAuthor(editCommit.getAuthorIdent()); |
| commit.setCommitter(new PersonIdent( |
| editCommit.getCommitterIdent(), TimeUtil.nowTs())); |
| commit.setMessage(editCommit.getFullMessage()); |
| ObjectId newEdit = inserter.insert(commit); |
| inserter.flush(); |
| |
| ru.addCommand(new ReceiveCommand(ObjectId.zeroId(), newEdit, |
| refName)); |
| ru.addCommand(new ReceiveCommand(edit.getRef().getObjectId(), |
| ObjectId.zeroId(), edit.getRefName())); |
| ru.execute(rw, NullProgressMonitor.INSTANCE); |
| for (ReceiveCommand cmd : ru.getCommands()) { |
| if (cmd.getResult() != ReceiveCommand.Result.OK) { |
| throw new IOException("failed: " + cmd); |
| } |
| } |
| } else { |
| // TODO(davido): Allow to resolve conflicts inline |
| throw new ResourceConflictException("merge conflict"); |
| } |
| } |
| } |
| |
| /** |
| * Modify commit message in existing change edit. |
| * |
| * @param edit change edit |
| * @param msg new commit message |
| * @return result |
| * @throws AuthException |
| * @throws InvalidChangeOperationException |
| * @throws IOException |
| * @throws UnchangedCommitMessageException |
| */ |
| public RefUpdate.Result modifyMessage(ChangeEdit edit, String msg) |
| throws AuthException, InvalidChangeOperationException, IOException, |
| UnchangedCommitMessageException { |
| msg = msg.trim() + "\n"; |
| checkState(!Strings.isNullOrEmpty(msg), "message cannot be null"); |
| if (!currentUser.get().isIdentifiedUser()) { |
| throw new AuthException("Authentication required"); |
| } |
| |
| RevCommit prevEdit = edit.getEditCommit(); |
| if (prevEdit.getFullMessage().equals(msg)) { |
| throw new UnchangedCommitMessageException(); |
| } |
| |
| IdentifiedUser me = (IdentifiedUser) currentUser.get(); |
| Project.NameKey project = edit.getChange().getProject(); |
| try (Repository repo = gitManager.openRepository(project); |
| RevWalk rw = new RevWalk(repo); |
| ObjectInserter inserter = repo.newObjectInserter()) { |
| String refName = edit.getRefName(); |
| ObjectId commit = createCommit(me, inserter, prevEdit, |
| prevEdit.getTree(), |
| msg); |
| inserter.flush(); |
| return update(repo, me, refName, rw, prevEdit, commit); |
| } |
| } |
| |
| /** |
| * Modify file in existing change edit from its base commit. |
| * |
| * @param edit change edit |
| * @param file path to modify |
| * @param content new content |
| * @return result |
| * @throws AuthException |
| * @throws InvalidChangeOperationException |
| * @throws IOException |
| */ |
| public RefUpdate.Result modifyFile(ChangeEdit edit, |
| String file, RawInput content) throws AuthException, |
| InvalidChangeOperationException, IOException { |
| return modify(TreeOperation.CHANGE_ENTRY, edit, file, null, content); |
| } |
| |
| /** |
| * Delete file in existing change edit. |
| * |
| * @param edit change edit |
| * @param file path to delete |
| * @return result |
| * @throws AuthException |
| * @throws InvalidChangeOperationException |
| * @throws IOException |
| */ |
| public RefUpdate.Result deleteFile(ChangeEdit edit, |
| String file) throws AuthException, InvalidChangeOperationException, |
| IOException { |
| return modify(TreeOperation.DELETE_ENTRY, edit, file, null, null); |
| } |
| |
| /** |
| * Rename file in existing change edit. |
| * |
| * @param edit change edit |
| * @param file path to rename |
| * @param newFile path to rename the file to |
| * @return result |
| * @throws AuthException |
| * @throws InvalidChangeOperationException |
| * @throws IOException |
| */ |
| public RefUpdate.Result renameFile(ChangeEdit edit, String file, |
| String newFile) throws AuthException, InvalidChangeOperationException, |
| IOException { |
| return modify(TreeOperation.RENAME_ENTRY, edit, file, newFile, null); |
| } |
| |
| /** |
| * Restore file in existing change edit. |
| * |
| * @param edit change edit |
| * @param file path to restore |
| * @return result |
| * @throws AuthException |
| * @throws InvalidChangeOperationException |
| * @throws IOException |
| */ |
| public RefUpdate.Result restoreFile(ChangeEdit edit, |
| String file) throws AuthException, InvalidChangeOperationException, |
| IOException { |
| return modify(TreeOperation.RESTORE_ENTRY, edit, file, null, null); |
| } |
| |
| private RefUpdate.Result modify(TreeOperation op, ChangeEdit edit, |
| String file, @Nullable String newFile, @Nullable RawInput content) |
| throws AuthException, IOException, InvalidChangeOperationException { |
| if (!currentUser.get().isIdentifiedUser()) { |
| throw new AuthException("Authentication required"); |
| } |
| IdentifiedUser me = (IdentifiedUser) currentUser.get(); |
| Project.NameKey project = edit.getChange().getProject(); |
| try (Repository repo = gitManager.openRepository(project); |
| RevWalk rw = new RevWalk(repo); |
| ObjectInserter inserter = repo.newObjectInserter(); |
| ObjectReader reader = repo.newObjectReader()) { |
| String refName = edit.getRefName(); |
| RevCommit prevEdit = edit.getEditCommit(); |
| ObjectId newTree = writeNewTree(op, |
| rw, |
| inserter, |
| prevEdit, |
| reader, |
| file, |
| newFile, |
| toBlob(inserter, content)); |
| if (ObjectId.equals(newTree, prevEdit.getTree())) { |
| throw new InvalidChangeOperationException("no changes were made"); |
| } |
| |
| ObjectId commit = createCommit(me, inserter, prevEdit, newTree); |
| inserter.flush(); |
| return update(repo, me, refName, rw, prevEdit, commit); |
| } |
| } |
| |
| private static ObjectId toBlob(ObjectInserter ins, @Nullable RawInput content) |
| throws IOException { |
| if (content == null) { |
| return null; |
| } |
| |
| long len = content.getContentLength(); |
| InputStream in = content.getInputStream(); |
| if (len < 0) { |
| return ins.insert(OBJ_BLOB, ByteStreams.toByteArray(in)); |
| } |
| return ins.insert(OBJ_BLOB, len, in); |
| } |
| |
| private ObjectId createCommit(IdentifiedUser me, ObjectInserter inserter, |
| RevCommit revision, ObjectId tree) throws IOException { |
| return createCommit(me, inserter, revision, tree, |
| revision.getFullMessage()); |
| } |
| |
| private ObjectId createCommit(IdentifiedUser me, ObjectInserter inserter, |
| RevCommit revision, ObjectId tree, String msg) |
| throws IOException { |
| CommitBuilder builder = new CommitBuilder(); |
| builder.setTreeId(tree); |
| builder.setParentIds(revision.getParents()); |
| builder.setAuthor(revision.getAuthorIdent()); |
| builder.setCommitter(getCommitterIdent(me)); |
| builder.setMessage(msg); |
| return inserter.insert(builder); |
| } |
| |
| private RefUpdate.Result update(Repository repo, IdentifiedUser me, |
| String refName, RevWalk rw, ObjectId oldObjectId, ObjectId newEdit) |
| throws IOException { |
| RefUpdate ru = repo.updateRef(refName); |
| ru.setExpectedOldObjectId(oldObjectId); |
| ru.setNewObjectId(newEdit); |
| ru.setRefLogIdent(getRefLogIdent(me)); |
| ru.setRefLogMessage("inline edit (amend)", false); |
| ru.setForceUpdate(true); |
| RefUpdate.Result res = ru.update(rw); |
| if (res != RefUpdate.Result.NEW && |
| res != RefUpdate.Result.FORCED) { |
| throw new IOException("update failed: " + ru); |
| } |
| return res; |
| } |
| |
| private static ObjectId writeNewTree(TreeOperation op, RevWalk rw, |
| ObjectInserter ins, RevCommit prevEdit, ObjectReader reader, |
| String fileName, @Nullable String newFile, |
| final @Nullable ObjectId content) throws IOException { |
| DirCache newTree = readTree(reader, prevEdit); |
| DirCacheEditor dce = newTree.editor(); |
| switch (op) { |
| case DELETE_ENTRY: |
| dce.add(new DeletePath(fileName)); |
| break; |
| |
| case RENAME_ENTRY: |
| rw.parseHeaders(prevEdit); |
| TreeWalk tw = |
| TreeWalk.forPath(rw.getObjectReader(), fileName, prevEdit.getTree()); |
| if (tw != null) { |
| dce.add(new DeletePath(fileName)); |
| addFileToCommit(newFile, dce, tw); |
| } |
| break; |
| |
| case CHANGE_ENTRY: |
| checkNotNull(content, "new content required"); |
| dce.add(new PathEdit(fileName) { |
| @Override |
| public void apply(DirCacheEntry ent) { |
| if (ent.getRawMode() == 0) { |
| ent.setFileMode(FileMode.REGULAR_FILE); |
| } |
| ent.setObjectId(content); |
| } |
| }); |
| break; |
| |
| case RESTORE_ENTRY: |
| if (prevEdit.getParentCount() == 0) { |
| dce.add(new DeletePath(fileName)); |
| break; |
| } |
| |
| RevCommit base = prevEdit.getParent(0); |
| rw.parseHeaders(base); |
| tw = TreeWalk.forPath(rw.getObjectReader(), fileName, base.getTree()); |
| if (tw == null) { |
| dce.add(new DeletePath(fileName)); |
| break; |
| } |
| |
| addFileToCommit(fileName, dce, tw); |
| break; |
| } |
| dce.finish(); |
| return newTree.writeTree(ins); |
| } |
| |
| private static void addFileToCommit(String newFile, DirCacheEditor dce, |
| TreeWalk tw) { |
| final FileMode mode = tw.getFileMode(0); |
| final ObjectId oid = tw.getObjectId(0); |
| dce.add(new PathEdit(newFile) { |
| @Override |
| public void apply(DirCacheEntry ent) { |
| ent.setFileMode(mode); |
| ent.setObjectId(oid); |
| } |
| }); |
| } |
| |
| private static DirCache readTree(ObjectReader reader, RevCommit prevEdit) |
| throws IOException { |
| DirCache dc = DirCache.newInCore(); |
| DirCacheBuilder b = dc.builder(); |
| b.addTree(new byte[0], DirCacheEntry.STAGE_0, reader, prevEdit.getTree()); |
| b.finish(); |
| return dc; |
| } |
| |
| private PersonIdent getCommitterIdent(IdentifiedUser user) { |
| return user.newCommitterIdent(TimeUtil.nowTs(), tz); |
| } |
| |
| private PersonIdent getRefLogIdent(IdentifiedUser user) { |
| return user.newRefLogIdent(TimeUtil.nowTs(), tz); |
| } |
| } |