Merge "Allow amending commits in ApplyPatch API"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index d5d68b3f..f28d619 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6875,6 +6875,10 @@
 caller.
 |`response_format_options`     |optional|
 List of link:#query-options[query options] to format the response.
+|`amend`              |optional|
+If true, the revision from the URL will be amended by the patch. This will use the tree of the
+revision, apply the patch and create a new commit whose tree is the resulting tree of the operation
+and whose parent(s) are the parent(s) of the revision. Cannot be used together with `base`.
 |=================================
 
 
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 28ef0ed..3de4ec9 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -923,10 +923,11 @@
       String subject,
       String fileName,
       String content,
-      String topic)
+      @Nullable String topic)
       throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo, subject, fileName, content);
-    return push.to("refs/for/" + branch + "%topic=" + name(topic));
+    return push.to(
+        "refs/for/" + branch + (Strings.isNullOrEmpty(topic) ? "" : "%topic=" + name(topic)));
   }
 
   protected BranchApi createBranch(BranchNameKey branch) throws Exception {
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
index cf114df..b843789 100644
--- a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
@@ -44,4 +44,11 @@
   @Nullable public AccountInput author;
 
   @Nullable public List<ListChangesOption> responseFormatOptions;
+
+  /**
+   * If {@code true}, the revision will be amended by the patch. This will use the tree of the
+   * revision, apply the patch and create a new commit whose tree is the resulting tree of the
+   * operation and whose parent(s) are the parent(s) of the revision.
+   */
+  @Nullable public Boolean amend;
 }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 11d755b..74f1355 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -70,6 +71,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -212,7 +214,7 @@
    * @param oi ObjectInserter for inserting the newly created commit.
    * @param authorIdent of the new commit
    * @param committerIdent of the new commit
-   * @param parentCommit of the new commit. Can be null.
+   * @param parents of the new commit. Can be empty.
    * @param commitMessage for the new commit.
    * @param treeId of the content for the new commit.
    * @return the newly created commit.
@@ -222,16 +224,14 @@
       ObjectInserter oi,
       PersonIdent authorIdent,
       PersonIdent committerIdent,
-      @Nullable RevCommit parentCommit,
+      List<RevCommit> parents,
       String commitMessage,
       ObjectId treeId)
       throws IOException {
     logger.atFine().log("Creating commit with tree: %s", treeId.getName());
     CommitBuilder commit = new CommitBuilder();
     commit.setTreeId(treeId);
-    if (parentCommit != null) {
-      commit.setParentId(parentCommit);
-    }
+    commit.setParentIds(parents.stream().map(RevCommit::getId).collect(Collectors.toList()));
     commit.setAuthor(authorIdent);
     commit.setCommitter(committerIdent);
     commit.setMessage(commitMessage);
@@ -294,7 +294,12 @@
     }
 
     return createCommitWithTree(
-        oi, authorIdent, committerIdent, commitToRevert, message, parentToCommitToRevert.getTree());
+        oi,
+        authorIdent,
+        committerIdent,
+        ImmutableList.of(commitToRevert),
+        message,
+        parentToCommitToRevert.getTree());
   }
 
   private Change.Id createRevertChangeFromCommit(
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
index 500d3df..022614a 100644
--- a/java/com/google/gerrit/server/patch/DiffUtil.java
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -174,8 +174,8 @@
         // Remove any lines which are not diff lines or file header lines - such index,
         // hunk-headers, and context lines.
         .replaceAll("(?m)^[^+-].*", "")
-        .replaceAll("(?m)^[+]{3} [ab]/", "+++")
-        .replaceAll("(?m)^-{3} [ab]/", "+++")
+        .replaceAll("(?m)^[+]{3} [ab]/", "+++ ")
+        .replaceAll("(?m)^-{3} [ab]/", "--- ")
         // Remove empty lines
         .replaceAll("\n+", "\n")
         // Trim
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
index 58cd010..ff37fd2 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -142,13 +142,18 @@
                 destChange.change().getStatus().name()));
       }
 
-      RevCommit latestPatchset = revWalk.parseCommit(destChange.currentPatchSet().commitId());
+      if (!Strings.isNullOrEmpty(input.base) && Boolean.TRUE.equals(input.amend)) {
+        throw new BadRequestException("amend only works with existing revisions. omit base.");
+      }
 
+      RevCommit latestPatchset = revWalk.parseCommit(destChange.currentPatchSet().commitId());
       RevCommit baseCommit;
+      List<RevCommit> parents;
       if (!Strings.isNullOrEmpty(input.base)) {
         baseCommit =
             CommitUtil.getBaseCommit(
                 project.get(), queryProvider.get(), revWalk, destRef, input.base);
+        parents = ImmutableList.of(baseCommit);
       } else {
         if (latestPatchset.getParentCount() != 1) {
           throw new BadRequestException(
@@ -156,7 +161,13 @@
                   "Cannot parse base commit for a change with none or multiple parents. Change ID: %s.",
                   destChange.getId()));
         }
-        baseCommit = revWalk.parseCommit(latestPatchset.getParent(0));
+        if (Boolean.TRUE.equals(input.amend)) {
+          baseCommit = latestPatchset;
+          parents = ImmutableList.copyOf(baseCommit.getParents());
+        } else {
+          baseCommit = revWalk.parseCommit(latestPatchset.getParent(0));
+          parents = ImmutableList.of(baseCommit);
+        }
       }
       PatchApplier.Result applyResult =
           ApplyPatchUtil.applyPatch(repo, oi, input.patch, baseCommit);
@@ -180,10 +191,9 @@
               input.patch.patch,
               ApplyPatchUtil.getResultPatch(repo, reader, baseCommit, revWalk.lookupTree(treeId)),
               applyResult.getErrors());
-
       ObjectId appliedCommit =
           CommitUtil.createCommitWithTree(
-              oi, authorIdent, committerIdent, baseCommit, commitMessage, treeId);
+              oi, authorIdent, committerIdent, parents, commitMessage, treeId);
       CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
       oi.flush();
 
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 4a70684..62752c2 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -422,7 +422,12 @@
           c =
               rw.parseCommit(
                   CommitUtil.createCommitWithTree(
-                      oi, author, committer, mergeTip, appliedPatchCommitMessage, treeId));
+                      oi,
+                      author,
+                      committer,
+                      ImmutableList.of(mergeTip),
+                      appliedPatchCommitMessage,
+                      treeId));
         } else {
           // create an empty commit.
           c = createEmptyCommit(oi, rw, author, committer, mergeTip, commitMessage);
@@ -595,14 +600,15 @@
       CodeReviewRevWalk rw,
       PersonIdent authorIdent,
       PersonIdent committerIdent,
-      RevCommit mergeTip,
+      @Nullable RevCommit mergeTip,
       String commitMessage)
       throws IOException {
     logger.atFine().log("Creating empty commit");
     ObjectId treeID = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree().getId();
+    List<RevCommit> parents = mergeTip == null ? ImmutableList.of() : ImmutableList.of(mergeTip);
     return rw.parseCommit(
         CommitUtil.createCommitWithTree(
-            oi, authorIdent, committerIdent, mergeTip, commitMessage, treeID));
+            oi, authorIdent, committerIdent, parents, commitMessage, treeID));
   }
 
   private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
index 6d51cb4..d9b01ad 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
@@ -46,10 +47,14 @@
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.testing.GitPersonSubject;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.util.Base64;
 import org.junit.Test;
@@ -516,6 +521,154 @@
         .isEqualTo("Default commit message\n\nChange-Id: " + result.changeId + "\n");
   }
 
+  @Test
+  public void amendCommitWithValidTraditionalPatch_success() throws Exception {
+    final String fileName = "file_name.txt";
+    final String originalContent = "original line";
+    final String newContent = "new line\n";
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-original line\n"
+            + "+new line\n";
+
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, "Test", fileName, "foo");
+    PushOneCommit.Result base = push.to("refs/heads/foo");
+    base.assertOkStatus();
+
+    PushOneCommit.Result firstPatchSet =
+        createChange(
+            testRepo, "foo", "Add original file: " + fileName, fileName, originalContent, null);
+    firstPatchSet.assertOkStatus();
+
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = diff;
+    in.amend = true;
+    in.responseFormatOptions =
+        ImmutableList.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+    ChangeInfo result = gApi.changes().id(firstPatchSet.getChangeId()).applyPatch(in);
+
+    // Parent of patch set 2 = parent of patch set 1, so we actually amended
+    assertThat(result.revisions.get(result.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(base.getCommit().getId().getName());
+    DiffInfo fileDiff = gApi.changes().id(result.id).current().file(fileName).diff();
+    assertDiffForFullyModifiedFile(fileDiff, result.currentRevision, fileName, "foo", newContent);
+    assertThat(gApi.changes().id(firstPatchSet.getChangeId()).current().commit(false).message)
+        .isEqualTo(firstPatchSet.getCommit().getFullMessage());
+  }
+
+  @Test
+  public void amendCantBeUsedWithBase() throws Exception {
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-original line\n"
+            + "+new line\n";
+    PushOneCommit.Result change = createChange();
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = diff;
+    in.amend = true;
+    in.base = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(change.getChangeId()).applyPatch(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("amend only works with existing revisions. omit base.");
+  }
+
+  @Test
+  public void amendCommitWithConflict_appendErrorsToCommitMessage() throws Exception {
+    final String fileName = "file_name.txt";
+    final String originalContent = "original line";
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-xxx line\n"
+            + "+new line\n";
+
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, "Test", fileName, "foo");
+    PushOneCommit.Result base = push.to("refs/heads/foo");
+    base.assertOkStatus();
+
+    PushOneCommit.Result firstPatchSet =
+        createChange(
+            testRepo, "foo", "Add original file: " + fileName, fileName, originalContent, null);
+    firstPatchSet.assertOkStatus();
+
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = diff;
+    in.amend = true;
+    in.responseFormatOptions =
+        ImmutableList.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+    ChangeInfo result = gApi.changes().id(firstPatchSet.getChangeId()).applyPatch(in);
+    assertThat(gApi.changes().id(result.id).current().commit(false).message)
+        .startsWith(
+            "Add original file: file_name.txt\n"
+                + "\n"
+                + "NOTE FOR REVIEWERS - errors occurred while applying the patch.\n"
+                + "PLEASE REVIEW CAREFULLY.\n"
+                + "Errors:\n"
+                + "Error applying patch in file_name.txt, hunk HunkHeader[1,1->1,1]: Hunk cannot be applied\n"
+                + "\n"
+                + "Original patch:\n"
+                + " diff file_name.txt file_name.txt\n"
+                + "--- file_name.txt\n"
+                + "+++ file_name.txt\n"
+                + "@@ -1 +1 @@\n"
+                + "-xxx line\n"
+                + "+new line");
+  }
+
+  @Test
+  public void amendCommitWithValidTraditionalPatchEmptyRepo_resourceNotFound() throws Exception {
+    final String fileName = "file_name.txt";
+    final String originalContent = "original line";
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-original line\n"
+            + "+new line\n";
+
+    Project.NameKey emptyProject = projectOperations.newProject().noEmptyCommit().create();
+    TestRepository<InMemoryRepository> emptyClone = cloneProject(emptyProject);
+    PushOneCommit.Result firstPatchSet =
+        createChange(
+            emptyClone,
+            "master",
+            "Add original file: " + fileName,
+            fileName,
+            originalContent,
+            null);
+    firstPatchSet.assertOkStatus();
+
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = diff;
+    in.amend = true;
+
+    Throwable error =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(firstPatchSet.getChangeId()).applyPatch(in));
+    assertThat(error).hasMessageThat().contains("Branch refs/heads/master does not exist");
+  }
+
   private void initDestBranch() throws Exception {
     String head = getHead(repo(), HEAD).name();
     createBranchWithRevision(BranchNameKey.create(project, ApplyPatchIT.DESTINATION_BRANCH), head);