Merge "Add a REST API to rebase a chain of changes"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index a5a6622..79284de 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1513,6 +1513,223 @@
   The change could not be rebased due to a path conflict during merge.
 ----
 
+[[rebase-chain]]
+=== Rebase Chain
+--
+'POST /changes/link:#change-id[\{change-id\}]/rebase:chain'
+--
+
+Rebases an ancestry chain of changes.
+
+The operated change is treated as the chain tip. All unsubmitted ancestors are rebased.
+
+Requires a linear ancestry relation (single parenting throughout the chain).
+
+Optionally, the parent revision (of the oldest ancestor to be rebased) can be changed to another
+change, revision or branch through the link:#rebase-input[RebaseInput] entity.
+
+If the chain is outdated, i.e., there's a change that depends on an old revision of its parent, the
+result is the same as individually rebasing all outdated changes on top of their parent's latest
+revision before running the rebase chain action.
+
+.Request
+----
+  POST /changes/myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f/rebase:chain HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "base" : "1234",
+  }
+----
+
+As response a link:#rebase-chain-info[RebaseChainInfo] entity is returned that
+describes the rebased changes. Information about the current patch sets
+are included.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "rebased_changes": [
+      {
+        "id": "myProject~master~I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "subject": "456",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "a2a6692213f546e1045ecf4647439fac8d6d8faa",
+        "_number": 21,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+        "revisions": {
+          "c3b2ba222d42a56e05c90f88d4509a124620517d": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/21/21/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "7803f427dd7c4a2441466e4d740a1850dcee1af4",
+                  "subject": "123"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 49: 39.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "456",
+              "message": "456\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "id": "myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "subject": "789",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "3bfb843fea471f96e16b9199c3a30fff0285bc45",
+        "_number": 22,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "77eb17a9501a5c21963bc6af56085e60f281acbb",
+        "revisions": {
+          "77eb17a9501a5c21963bc6af56085e60f281acbb": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/22/22/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+                  "subject": "456"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 51: 07.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "789",
+              "message": "789\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      }
+    ],
+  }
+----
+
+If the change cannot be rebased, e.g. due to conflicts, the response is
+"`409 Conflict`" and the error message is contained in the response
+body.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  Change I0e534de9d7f0d6f35b71f7d726acf835b2110c66 could not be rebased due to a conflict during
+  merge.
+
+  merge conflict(s):
+  a.txt
+----
+
 [[move-change]]
 === Move Change
 --
@@ -7995,6 +8212,21 @@
 options. Unknown validation options are silently ignored.
 |===========================
 
+[[rebase-chain-info]]
+=== RebaseChainInfo
+
+The `RebaseChainInfo` entity contains information about a chain of changes
+that were rebased.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name                ||Description
+|`rebased_changes`         ||List of the unsubmitted ancestors, as link:#change-info[ChangeInfo]
+entities. Includes both rebased changes, and previously up-to-date ancestors. The list is ordered by
+ancestry, where the oldest ancestor is the first.
+|`contains_git_conflicts`  ||Whether any of the rebased changes has conflicts
+due to rebasing.
+
 [[related-change-and-commit-info]]
 === RelatedChangeAndCommitInfo
 
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index cce28e9..0ebb859 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -27,12 +27,14 @@
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -178,6 +180,24 @@
   /** Rebase the current revision of a change. */
   void rebase(RebaseInput in) throws RestApiException;
 
+  /**
+   * Rebase the current revisions of a change's chain using default options.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  default Response<RebaseChainInfo> rebaseChain() throws RestApiException {
+    return rebaseChain(new RebaseInput());
+  }
+
+  /**
+   * Rebase the current revisions of a change's chain.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException;
+
   /** Deletes a change. */
   void delete() throws RestApiException;
 
@@ -634,6 +654,11 @@
     }
 
     @Override
+    public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void delete() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/common/RebaseChainInfo.java b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
new file mode 100644
index 0000000..b327007
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 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.common;
+
+import java.util.List;
+
+public class RebaseChainInfo {
+  public List<ChangeInfo> rebasedChanges;
+  /**
+   * Whether any of the changes contain conflicts.
+   *
+   * <p>If {@code true}, some of the rebased changes are marked with conflicts.
+   */
+  public Boolean containsGitConflicts;
+}
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 3d449b7..2962108 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -35,11 +35,13 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.RepoContext;
 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 java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
@@ -169,4 +171,54 @@
       return src;
     }
   }
+
+  /**
+   * Gets the commit ID for the latest patch-set of a given change.
+   *
+   * <p>This also takes into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param ctx to look for pending updates in.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed nor pending commits found for the change.
+   */
+  public static RevCommit getCurrentRevCommitIncludingPending(
+      RepoContext ctx, ChangeNotes.Factory notesFactory, Change.Id changeId) throws IOException {
+    Map<String, ObjectId> refUpdates = ctx.getRepoView().getRefs(changeId.toRefPrefix());
+    refUpdates.remove("meta");
+    if (!refUpdates.isEmpty()) {
+      Optional<PatchSet.Id> latestPendingPatchSet =
+          refUpdates.keySet().stream()
+              .map(r -> PatchSet.Id.fromRef(changeId.toRefPrefix() + r))
+              .max(PatchSet.Id::compareTo);
+      if (latestPendingPatchSet.isPresent()) {
+        return ctx.getRevWalk().parseCommit(refUpdates.get(latestPendingPatchSet.get().getId()));
+      }
+    }
+    return getCurrentCommittedRevCommit(ctx.getProject(), ctx.getRevWalk(), notesFactory, changeId);
+  }
+
+  /**
+   * Gets the commit ID for the latest committed patch-set of a given change.
+   *
+   * <p>This DOES NOT take into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param project name.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed commits found for the change.
+   */
+  public static RevCommit getCurrentCommittedRevCommit(
+      Project.NameKey project,
+      RevWalk revWalk,
+      ChangeNotes.Factory notesFactory,
+      Change.Id changeId)
+      throws IOException {
+    ChangeNotes notes = notesFactory.createChecked(project, changeId);
+    return revWalk.parseCommit(notes.getCurrentPatchSet().commitId());
+  }
 }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index e0569f4..66a845a 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
@@ -100,6 +101,7 @@
 import com.google.gerrit.server.restapi.change.PutMessage;
 import com.google.gerrit.server.restapi.change.PutTopic;
 import com.google.gerrit.server.restapi.change.Rebase;
+import com.google.gerrit.server.restapi.change.RebaseChain;
 import com.google.gerrit.server.restapi.change.Restore;
 import com.google.gerrit.server.restapi.change.Revert;
 import com.google.gerrit.server.restapi.change.RevertSubmission;
@@ -144,6 +146,7 @@
   private final ApplyPatch applyPatch;
   private final Provider<SubmittedTogether> submittedTogether;
   private final Rebase.CurrentRevision rebase;
+  private final RebaseChain rebaseChain;
   private final DeleteChange deleteChange;
   private final GetTopic getTopic;
   private final PutTopic putTopic;
@@ -197,6 +200,7 @@
       ApplyPatch applyPatch,
       Provider<SubmittedTogether> submittedTogether,
       Rebase.CurrentRevision rebase,
+      RebaseChain rebaseChain,
       DeleteChange deleteChange,
       GetTopic getTopic,
       PutTopic putTopic,
@@ -248,6 +252,7 @@
     this.applyPatch = applyPatch;
     this.submittedTogether = submittedTogether;
     this.rebase = rebase;
+    this.rebaseChain = rebaseChain;
     this.deleteChange = deleteChange;
     this.getTopic = getTopic;
     this.putTopic = putTopic;
@@ -427,6 +432,15 @@
   }
 
   @Override
+  public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+    try {
+      return rebaseChain.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase chain", e);
+    }
+  }
+
+  @Override
   public void delete() throws RestApiException {
     try {
       deleteChange.apply(change, null);
diff --git a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
index 194a4f0..27eeae1 100644
--- a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -65,6 +65,34 @@
    */
   public List<RelatedChangesSorter.PatchSetData> getRelated(ChangeData changeData, PatchSet basePs)
       throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, false);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sort(cds, basePs);
+  }
+
+  /**
+   * Gets ancestor changes of a specific change revision.
+   *
+   * @param changeData the change of the inputted revision.
+   * @param basePs the revision that the method checks for related changes.
+   * @param alwaysIncludeOriginalChange whether to return the given change when no ancestors found.
+   * @return list of ancestor changes, sorted via {@link RelatedChangesSorter}
+   */
+  public List<RelatedChangesSorter.PatchSetData> getAncestors(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange)
+      throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, alwaysIncludeOriginalChange);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sortAncestors(cds, basePs);
+  }
+
+  private List<ChangeData> getUnsortedRelated(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange)
+      throws IOException, PermissionBackendException {
     Set<String> groups = getAllGroups(changeData.patchSets());
     logger.atFine().log("groups = %s", groups);
     if (groups.isEmpty()) {
@@ -78,12 +106,10 @@
       return Collections.emptyList();
     }
     if (cds.size() == 1 && cds.get(0).getId().equals(changeData.getId())) {
-      return Collections.emptyList();
+      return alwaysIncludeOriginalChange ? cds : Collections.emptyList();
     }
 
-    cds = reloadChangeIfStale(cds, changeData, basePs);
-
-    return sorter.sort(cds, basePs);
+    return reloadChangeIfStale(cds, changeData, basePs);
   }
 
   private List<ChangeData> reloadChangeIfStale(
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 4de21d6..49ec812 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -22,7 +22,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -30,6 +32,8 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -46,8 +50,8 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -73,19 +77,24 @@
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
+
+    RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, Change.Id baseChangeId);
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final MergeUtilFactory mergeUtilFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeNotes.Factory notesFactory;
 
   private final ChangeNotes notes;
   private final PatchSet originalPatchSet;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final ProjectCache projectCache;
+  private final Project.NameKey projectName;
 
   private ObjectId baseCommitId;
+  private Change.Id baseChangeId;
   private PersonIdent committerIdent;
   private boolean fireRevisionCreated = true;
   private boolean validate = true;
@@ -104,26 +113,78 @@
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
 
-  @Inject
+  @AssistedInject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtilFactory mergeUtilFactory,
       RebaseUtil rebaseUtil,
       ChangeResource.Factory changeResourceFactory,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
       ProjectCache projectCache,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet originalPatchSet,
       @Assisted ObjectId baseCommitId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseCommitId = baseCommitId;
+    this.baseChangeId = null;
+  }
+
+  @AssistedInject
+  RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted PatchSet originalPatchSet,
+      @Assisted Change.Id baseChangeId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseChangeId = baseChangeId;
+    this.baseCommitId = null;
+  }
+
+  private RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      ChangeNotes notes,
+      PatchSet originalPatchSet) {
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.rebaseUtil = rebaseUtil;
     this.changeResourceFactory = changeResourceFactory;
+    this.notesFactory = notesFactory;
     this.identifiedUserFactory = identifiedUserFactory;
     this.projectCache = projectCache;
     this.notes = notes;
+    this.projectName = notes.getProjectName();
     this.originalPatchSet = originalPatchSet;
-    this.baseCommitId = baseCommitId;
   }
 
   public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
@@ -204,14 +265,23 @@
 
   @Override
   public void updateRepo(RepoContext ctx)
-      throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
-          NoSuchChangeException, PermissionBackendException {
+      throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
+          PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(originalPatchSet.commitId());
     rw.parseBody(original);
-    RevCommit baseCommit = rw.parseCommit(baseCommitId);
+    RevCommit baseCommit;
+    if (baseCommitId != null && baseChangeId == null) {
+      baseCommit = rw.parseCommit(baseCommitId);
+    } else if (baseChangeId != null) {
+      baseCommit =
+          PatchSetUtil.getCurrentRevCommitIncludingPending(ctx, notesFactory, baseChangeId);
+    } else {
+      throw new IllegalStateException(
+          "Exactly one of base commit and base change must be provided.");
+    }
     CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
 
     String newCommitMessage;
@@ -224,12 +294,12 @@
       newCommitMessage = original.getFullMessage();
     }
 
-    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
+    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage, notes.getChangeId());
     Base base =
         rebaseUtil.parseBase(
             new RevisionResource(
                 changeResourceFactory.create(notes, changeOwner), originalPatchSet),
-            baseCommitId.name());
+            baseCommit.getName());
 
     rebasedPatchSetId =
         ChangeUtil.nextPatchSetIdFromChangeRefs(
@@ -320,8 +390,7 @@
   }
 
   private MergeUtil newMergeUtil() {
-    ProjectState project =
-        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
+    ProjectState project = projectCache.get(projectName).orElseThrow(illegalState(projectName));
     return forceContentMerge
         ? mergeUtilFactory.create(project, true)
         : mergeUtilFactory.create(project);
@@ -338,7 +407,11 @@
    * @throws IOException the merge failed for another reason.
    */
   private CodeReviewCommit rebaseCommit(
-      RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
+      RepoContext ctx,
+      RevCommit original,
+      ObjectId base,
+      String commitMessage,
+      Change.Id originalChangeId)
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
@@ -372,8 +445,9 @@
 
       if (!allowConflicts || !(merger instanceof ResolveMerger)) {
         throw new MergeConflictException(
-            "The change could not be rebased due to a conflict during merge.\n\n"
-                + MergeUtil.createConflictMessage(conflicts));
+            String.format(
+                "Change %s could not be rebased due to a conflict during merge.\n\n%s",
+                originalChangeId.toString(), MergeUtil.createConflictMessage(conflicts)));
       }
 
       Map<String, MergeResult<? extends Sequence>> mergeResults =
@@ -413,7 +487,6 @@
               cb.getAuthor(), cb.getCommitter().getWhen(), cb.getCommitter().getTimeZone()));
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
-    ctx.getInserter().flush();
     CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
     commit.setFilesWithGitConflicts(filesWithGitConflicts);
     return commit;
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index ba938ee..dcbd1ae 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
@@ -22,12 +24,20 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 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.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
@@ -46,20 +56,67 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
+  private final RebaseChangeOp.Factory rebaseFactory;
 
   @Inject
   RebaseUtil(
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      RebaseChangeOp.Factory rebaseFactory) {
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
+    this.rebaseFactory = rebaseFactory;
+  }
+
+  public static void verifyRebasePreconditions(
+      ProjectCache projectCache, PatchSetUtil patchSetUtil, RevWalk rw, RevisionResource rsrc)
+      throws ResourceConflictException, IOException, AuthException, PermissionBackendException {
+    // Not allowed to rebase if the current patch set is locked.
+    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
+
+    rsrc.permissions().check(ChangePermission.REBASE);
+    projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .checkStatePermitsWrite();
+
+    if (!rsrc.getChange().isNew()) {
+      throw new ResourceConflictException(
+          String.format(
+              "Change %s is %s", rsrc.getChange().getId(), ChangeUtil.status(rsrc.getChange())));
+    } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
+      throw new ResourceConflictException(
+          String.format(
+              "Error rebasing %s. Cannot rebase %s",
+              rsrc.getChange().getId(),
+              countParents(rw, rsrc.getPatchSet()) > 1
+                  ? "merge commits"
+                  : "commit with no ancestor"));
+    }
+  }
+
+  public static boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
+    // Prevent rebase of exotic changes (merge commit, no ancestor).
+    return countParents(rw, ps) == 1;
+  }
+
+  private static int countParents(RevWalk rw, PatchSet ps) throws IOException {
+    RevCommit c = rw.parseCommit(ps.commitId());
+    return c.getParentCount();
+  }
+
+  private static boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
+    ObjectId baseId = base.commitId();
+    ObjectId tipId = tip.commitId();
+    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
   }
 
   public boolean canRebase(PatchSet patchSet, BranchNameKey dest, Repository git, RevWalk rw) {
     try {
-      findBaseRevision(patchSet, dest, git, rw);
+      @SuppressWarnings("unused")
+      ObjectId base = findBaseRevision(patchSet, dest, git, rw, true);
       return true;
     } catch (RestApiException e) {
       return false;
@@ -129,6 +186,100 @@
   }
 
   /**
+   * Parse or find the commit onto which a patch set should be rebased.
+   *
+   * <p>If a {@code rebaseInput.base} is provided, parse it. Otherwise, finds the latest patch set
+   * of the change corresponding to this commit's parent, or the destination branch tip in the case
+   * where the parent's change is merged.
+   *
+   * @param git the repository.
+   * @param rw the RevWalk.
+   * @param permissionBackend to check base reading permissions with.
+   * @param rsrc to find the base for
+   * @param rebaseInput to optionally parse the base from.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
+   * @return the commit onto which the patch set should be rebased.
+   * @throws RestApiException if rebase is not possible.
+   * @throws IOException if accessing the repository fails.
+   * @throws PermissionBackendException if the user don't have permissions to read the base change.
+   */
+  public ObjectId parseOrFindBaseRevision(
+      Repository git,
+      RevWalk rw,
+      PermissionBackend permissionBackend,
+      RevisionResource rsrc,
+      RebaseInput rebaseInput,
+      boolean verifyNeedsRebase)
+      throws RestApiException, IOException, PermissionBackendException {
+    Change change = rsrc.getChange();
+
+    if (rebaseInput == null || rebaseInput.base == null) {
+      return findBaseRevision(rsrc.getPatchSet(), change.getDest(), git, rw, verifyNeedsRebase);
+    }
+
+    String inputBase = rebaseInput.base.trim();
+
+    if (inputBase.isEmpty()) {
+      return getDestRefTip(git, change.getDest());
+    }
+
+    Base base;
+    try {
+      base = parseBase(rsrc, inputBase);
+    } catch (NoSuchChangeException e) {
+      throw new UnprocessableEntityException(
+          String.format("Base change not found: %s", inputBase), e);
+    }
+    if (base == null) {
+      throw new ResourceConflictException(
+          "base revision is missing from the destination branch: " + inputBase);
+    }
+    return getLatestRevisionForBaseChange(rw, permissionBackend, rsrc, base);
+  }
+
+  private ObjectId getDestRefTip(Repository git, BranchNameKey destRefKey)
+      throws ResourceConflictException, IOException {
+    // Remove existing dependency to other patch set.
+    Ref destRef = git.exactRef(destRefKey.branch());
+    if (destRef == null) {
+      throw new ResourceConflictException(
+          "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
+    }
+    return destRef.getObjectId();
+  }
+
+  private ObjectId getLatestRevisionForBaseChange(
+      RevWalk rw, PermissionBackend permissionBackend, RevisionResource childRsrc, Base base)
+      throws ResourceConflictException, AuthException, PermissionBackendException, IOException {
+
+    Change child = childRsrc.getChange();
+    PatchSet.Id baseId = base.patchSet().id();
+    if (child.getId().equals(baseId.changeId())) {
+      throw new ResourceConflictException(
+          String.format("cannot rebase change %s onto itself", childRsrc.getChange().getId()));
+    }
+
+    permissionBackend.user(childRsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
+
+    Change baseChange = base.notes().getChange();
+    if (!baseChange.getProject().equals(child.getProject())) {
+      throw new ResourceConflictException(
+          "base change is in wrong project: " + baseChange.getProject());
+    } else if (!baseChange.getDest().equals(child.getDest())) {
+      throw new ResourceConflictException(
+          "base change is targeting wrong branch: " + baseChange.getDest());
+    } else if (baseChange.isAbandoned()) {
+      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
+    } else if (isMergedInto(rw, childRsrc.getPatchSet(), base.patchSet())) {
+      throw new ResourceConflictException(
+          "base change "
+              + baseChange.getKey()
+              + " is a descendant of the current change - recursion not allowed");
+    }
+    return base.patchSet().commitId();
+  }
+
+  /**
    * Find the commit onto which a patch set should be rebased.
    *
    * <p>This is defined as the latest patch set of the change corresponding to this commit's parent,
@@ -138,12 +289,17 @@
    * @param destBranch the destination branch.
    * @param git the repository.
    * @param rw the RevWalk.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
    * @return the commit onto which the patch set should be rebased.
    * @throws RestApiException if rebase is not possible.
    * @throws IOException if accessing the repository fails.
    */
   public ObjectId findBaseRevision(
-      PatchSet patchSet, BranchNameKey destBranch, Repository git, RevWalk rw)
+      PatchSet patchSet,
+      BranchNameKey destBranch,
+      Repository git,
+      RevWalk rw,
+      boolean verifyNeedsRebase)
       throws RestApiException, IOException {
     ObjectId baseId = null;
     RevCommit commit = rw.parseCommit(patchSet.commitId());
@@ -170,7 +326,7 @@
         }
 
         if (depChange.isNew()) {
-          if (depPatchSet.id().equals(depChange.currentPatchSetId())) {
+          if (verifyNeedsRebase && depPatchSet.id().equals(depChange.currentPatchSetId())) {
             throw new ResourceConflictException(
                 "Change is already based on the latest patch set of the dependent change.");
           }
@@ -189,10 +345,29 @@
             "The destination branch does not exist: " + destBranch.branch());
       }
       baseId = destRef.getObjectId();
-      if (baseId.equals(parentId)) {
+      if (verifyNeedsRebase && baseId.equals(parentId)) {
         throw new ResourceConflictException("Change is already up to date.");
       }
     }
     return baseId;
   }
+
+  public RebaseChangeOp getRebaseOp(RevisionResource revRsrc, RebaseInput input, ObjectId baseRev) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseRev), input);
+  }
+
+  public RebaseChangeOp getRebaseOp(
+      RevisionResource revRsrc, RebaseInput input, Change.Id baseChange) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseChange), input);
+  }
+
+  private RebaseChangeOp applyRebaseInputToOp(RebaseChangeOp op, RebaseInput input) {
+    return op.setForceContentMerge(true)
+        .setAllowConflicts(input.allowConflicts)
+        .setValidationOptions(
+            ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
+        .setFireRevisionCreated(true);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index b6e3121..f4b1a83c 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -75,16 +75,7 @@
     checkArgument(!in.isEmpty(), "Input may not be empty");
     // Map of all patch sets, keyed by commit SHA-1.
     Map<ObjectId, PatchSetData> byId = collectById(in);
-    PatchSetData start = byId.get(startPs.commitId());
-    requireNonNull(
-        start,
-        () ->
-            String.format(
-                "commit %s of patch set %s not found in %s",
-                startPs.commitId().name(),
-                startPs.id(),
-                byId.entrySet().stream()
-                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
 
     // Map of patch set -> immediate parent.
     ListMultimap<PatchSetData, PatchSetData> parents =
@@ -120,6 +111,34 @@
     return result;
   }
 
+  public List<PatchSetData> sortAncestors(List<ChangeData> in, PatchSet startPs)
+      throws IOException, PermissionBackendException {
+    checkArgument(!in.isEmpty(), "Input may not be empty");
+    // Map of all patch sets, keyed by commit SHA-1.
+    Map<ObjectId, PatchSetData> byId = collectById(in);
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
+
+    // Map of patch set -> immediate parent.
+    ListMultimap<PatchSetData, PatchSetData> parents =
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
+
+    for (ChangeData cd : in) {
+      for (PatchSet ps : cd.patchSets()) {
+        PatchSetData thisPsd = requireNonNull(byId.get(ps.commitId()));
+
+        for (RevCommit p : thisPsd.commit().getParents()) {
+          PatchSetData parentPsd = byId.get(p);
+          if (parentPsd != null) {
+            parents.put(thisPsd, parentPsd);
+          }
+        }
+      }
+    }
+
+    Collection<PatchSetData> ancestors = walkAncestors(parents, start);
+    return List.copyOf(ancestors);
+  }
+
   private Map<ObjectId, PatchSetData> collectById(List<ChangeData> in) throws IOException {
     Project.NameKey project = in.get(0).change().getProject();
     Map<ObjectId, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
@@ -143,6 +162,19 @@
     return result;
   }
 
+  private PatchSetData getCheckedPatchSetData(Map<ObjectId, PatchSetData> byId, PatchSet ps) {
+    PatchSetData psData = byId.get(ps.commitId());
+    return requireNonNull(
+        psData,
+        () ->
+            String.format(
+                "commit %s of patch set %s not found in %s",
+                ps.commitId().name(),
+                ps.id(),
+                byId.entrySet().stream()
+                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+  }
+
   private Collection<PatchSetData> walkAncestors(
       ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
       throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/change/ValidationOptionsUtil.java b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
new file mode 100644
index 0000000..137239c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 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.change;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+
+/** Utilities for validation options parsing. */
+public final class ValidationOptionsUtil {
+  public static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
+  private ValidationOptionsUtil() {}
+}
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 2841f92..f0b2a78 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -41,6 +40,7 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
@@ -64,7 +64,6 @@
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
@@ -317,7 +316,8 @@
             .create(changeId, revertCommit, changeToRevert.getDest().branch())
             .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
     ins.setMessage("Uploaded patch set 1.");
-    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+    ins.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
@@ -344,20 +344,6 @@
     return changeId;
   }
 
-  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
-      @Nullable Map<String, String> validationOptions) {
-    if (validationOptions == null) {
-      return ImmutableListMultimap.of();
-    }
-
-    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
-        ImmutableListMultimap.builder();
-    validationOptions
-        .entrySet()
-        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
-    return validationOptionsBuilder.build();
-  }
-
   /**
    * Notify the owners of a change that their change is being reverted.
    *
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 87d8cd0..f49ee7f 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -113,6 +113,7 @@
     post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
     get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
     post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
+    post(CHANGE_KIND, "rebase:chain").to(RebaseChain.class);
     post(CHANGE_KIND, "index").to(Index.class);
     post(CHANGE_KIND, "move").to(Move.class);
     post(CHANGE_KIND, "private").to(PostPrivate.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 7e7892c..c192500 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -19,7 +19,6 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -42,6 +41,7 @@
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.ResetCherryPickOp;
 import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.CommitUtil;
@@ -72,7 +72,6 @@
 import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -403,7 +402,8 @@
     if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
       inserter.setWorkInProgress(false);
     }
-    inserter.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+    inserter.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -454,7 +454,8 @@
           (sourceChange != null && sourceChange.isWorkInProgress())
               || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
     }
-    ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+    ins.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
@@ -500,20 +501,6 @@
     return changeId;
   }
 
-  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
-      @Nullable Map<String, String> validationOptions) {
-    if (validationOptions == null) {
-      return ImmutableListMultimap.of();
-    }
-
-    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
-        ImmutableListMultimap.builder();
-    validationOptions
-        .entrySet()
-        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
-    return validationOptionsBuilder.build();
-  }
-
   private NotifyResolver.Result resolveNotify(CherryPickInput input)
       throws BadRequestException, ConfigInvalidException, IOException {
     return notifyResolver.resolve(
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 5e30dae..1a8f07a 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -16,38 +16,30 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 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.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.RebaseUtil;
-import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.GitRepositoryManager;
 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.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
@@ -55,13 +47,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
@@ -72,7 +60,6 @@
 
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
-  private final RebaseChangeOp.Factory rebaseFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeJson.Factory json;
   private final PermissionBackend permissionBackend;
@@ -83,7 +70,6 @@
   public Rebase(
       BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
-      RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
       PermissionBackend permissionBackend,
@@ -91,7 +77,6 @@
       PatchSetUtil patchSetUtil) {
     this.updateFactory = updateFactory;
     this.repoManager = repoManager;
-    this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
     this.json = json;
     this.permissionBackend = permissionBackend;
@@ -102,15 +87,6 @@
   @Override
   public Response<ChangeInfo> apply(RevisionResource rsrc, RebaseInput input)
       throws UpdateException, RestApiException, IOException, PermissionBackendException {
-    // Not allowed to rebase if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
-
-    rsrc.permissions().check(ChangePermission.REBASE);
-    projectCache
-        .get(rsrc.getProject())
-        .orElseThrow(illegalState(rsrc.getProject()))
-        .checkStatePermitsWrite();
-
     Change change = rsrc.getChange();
     try (Repository repo = repoManager.openRepository(change.getProject());
         ObjectInserter oi = repo.newObjectInserter();
@@ -118,19 +94,14 @@
         RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
-      if (!change.isNew()) {
-        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-      } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
-        throw new ResourceConflictException(
-            "cannot rebase merge commits or commit with no ancestor");
-      }
+      RebaseUtil.verifyRebasePreconditions(projectCache, patchSetUtil, rw, rsrc);
+
       RebaseChangeOp rebaseOp =
-          rebaseFactory
-              .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
-              .setForceContentMerge(true)
-              .setAllowConflicts(input.allowConflicts)
-              .setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions))
-              .setFireRevisionCreated(true);
+          rebaseUtil.getRebaseOp(
+              rsrc,
+              input,
+              rebaseUtil.parseOrFindBaseRevision(repo, rw, permissionBackend, rsrc, input, true));
+
       // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
       bu.setNotify(NotifyResolver.Result.none());
       bu.setRepository(repo, rw, oi);
@@ -144,76 +115,6 @@
     }
   }
 
-  private ObjectId findBaseRev(
-      Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws RestApiException, IOException, NoSuchChangeException, AuthException,
-          PermissionBackendException {
-    BranchNameKey destRefKey = rsrc.getChange().getDest();
-    if (input == null || input.base == null) {
-      return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
-    }
-
-    Change change = rsrc.getChange();
-    String str = input.base.trim();
-    if (str.equals("")) {
-      // Remove existing dependency to other patch set.
-      Ref destRef = repo.exactRef(destRefKey.branch());
-      if (destRef == null) {
-        throw new ResourceConflictException(
-            "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
-      }
-      return destRef.getObjectId();
-    }
-
-    Base base;
-    try {
-      base = rebaseUtil.parseBase(rsrc, str);
-      if (base == null) {
-        throw new ResourceConflictException(
-            "base revision is missing from the destination branch: " + str);
-      }
-    } catch (NoSuchChangeException e) {
-      throw new UnprocessableEntityException(
-          String.format("Base change not found: %s", input.base), e);
-    }
-
-    PatchSet.Id baseId = base.patchSet().id();
-    if (change.getId().equals(baseId.changeId())) {
-      throw new ResourceConflictException("cannot rebase change onto itself");
-    }
-
-    permissionBackend.user(rsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
-
-    Change baseChange = base.notes().getChange();
-    if (!baseChange.getProject().equals(change.getProject())) {
-      throw new ResourceConflictException(
-          "base change is in wrong project: " + baseChange.getProject());
-    } else if (!baseChange.getDest().equals(change.getDest())) {
-      throw new ResourceConflictException(
-          "base change is targeting wrong branch: " + baseChange.getDest());
-    } else if (baseChange.isAbandoned()) {
-      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
-    } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
-      throw new ResourceConflictException(
-          "base change "
-              + baseChange.getKey()
-              + " is a descendant of the current change - recursion not allowed");
-    }
-    return base.patchSet().commitId();
-  }
-
-  private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
-    ObjectId baseId = base.commitId();
-    ObjectId tipId = tip.commitId();
-    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
-  }
-
-  private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
-    // Prevent rebase of exotic changes (merge commit, no ancestor).
-    RevCommit c = rw.parseCommit(ps.commitId());
-    return c.getParentCount() == 1;
-  }
-
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) throws IOException {
     UiAction.Description description =
@@ -241,7 +142,7 @@
     boolean enabled = false;
     try (Repository repo = repoManager.openRepository(change.getDest().project());
         RevWalk rw = new RevWalk(repo)) {
-      if (hasOneParent(rw, rsrc.getPatchSet())) {
+      if (RebaseUtil.hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
       }
     }
@@ -252,20 +153,6 @@
     return description;
   }
 
-  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
-      @Nullable Map<String, String> validationOptions) {
-    if (validationOptions == null) {
-      return ImmutableListMultimap.of();
-    }
-
-    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
-        ImmutableListMultimap.builder();
-    validationOptions
-        .entrySet()
-        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
-    return validationOptionsBuilder.build();
-  }
-
   public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
new file mode 100644
index 0000000..4754c69
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -0,0 +1,272 @@
+// Copyright (C) 2022 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.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+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.extensions.webui.UiAction;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.GetRelatedChangesUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RebaseUtil;
+import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Rest API for rebasing an ancestry chain of changes. */
+@Singleton
+public class RebaseChain
+    implements RestModifyView<ChangeResource, RebaseInput>, UiAction<ChangeResource> {
+  private static final ImmutableSet<ListChangesOption> OPTIONS =
+      Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+  private final GitRepositoryManager repoManager;
+  private final RebaseUtil rebaseUtil;
+  private final GetRelatedChangesUtil getRelatedChangesUtil;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final PermissionBackend permissionBackend;
+  private final BatchUpdate.Factory updateFactory;
+  private final ChangeNotes.Factory notesFactory;
+  private final ProjectCache projectCache;
+  private final PatchSetUtil patchSetUtil;
+  private final ChangeJson.Factory json;
+
+  @Inject
+  RebaseChain(
+      GitRepositoryManager repoManager,
+      RebaseUtil rebaseUtil,
+      GetRelatedChangesUtil getRelatedChangesUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeData.Factory changeDataFactory,
+      PermissionBackend permissionBackend,
+      BatchUpdate.Factory updateFactory,
+      ChangeNotes.Factory notesFactory,
+      ProjectCache projectCache,
+      PatchSetUtil patchSetUtil,
+      ChangeJson.Factory json) {
+    this.repoManager = repoManager;
+    this.getRelatedChangesUtil = getRelatedChangesUtil;
+    this.changeDataFactory = changeDataFactory;
+    this.rebaseUtil = rebaseUtil;
+    this.changeResourceFactory = changeResourceFactory;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.notesFactory = notesFactory;
+    this.projectCache = projectCache;
+    this.patchSetUtil = patchSetUtil;
+    this.json = json;
+  }
+
+  @Override
+  public Response<RebaseChainInfo> apply(ChangeResource tipRsrc, RebaseInput input)
+      throws IOException, PermissionBackendException, RestApiException, UpdateException {
+    Project.NameKey project = tipRsrc.getProject();
+    CurrentUser user = tipRsrc.getUser();
+
+    List<Change.Id> upToDateAncestors = new ArrayList<>();
+    Map<Change.Id, RebaseChangeOp> rebaseOps = new LinkedHashMap<>();
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = CodeReviewCommit.newRevWalk(reader);
+        BatchUpdate bu = updateFactory.create(project, user, TimeUtil.now())) {
+      List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+
+      boolean ancestorsAreUpToDate = true;
+      for (int i = 0; i < chain.size(); i++) {
+        ChangeData changeData = chain.get(i).data();
+        PatchSet ps = patchSetUtil.current(changeData.notes());
+        if (ps == null) {
+          throw new IllegalStateException(
+              "current revision is missing for change " + changeData.getId());
+        }
+
+        RevisionResource revRsrc =
+            new RevisionResource(changeResourceFactory.create(changeData, user), ps);
+        RebaseUtil.verifyRebasePreconditions(projectCache, patchSetUtil, rw, revRsrc);
+
+        boolean isUpToDate = false;
+        RebaseChangeOp rebaseOp = null;
+        if (i == 0) {
+          ObjectId desiredBase =
+              rebaseUtil.parseOrFindBaseRevision(
+                  repo, rw, permissionBackend, revRsrc, input, false);
+          if (currentBase(rw, ps).equals(desiredBase)) {
+            isUpToDate = true;
+          } else {
+            rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, desiredBase);
+          }
+        } else {
+          if (ancestorsAreUpToDate) {
+            ObjectId latestCommittedBase =
+                PatchSetUtil.getCurrentCommittedRevCommit(
+                    project, rw, notesFactory, chain.get(i - 1).id());
+            isUpToDate = currentBase(rw, ps).equals(latestCommittedBase);
+          }
+          if (!isUpToDate) {
+            rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, chain.get(i - 1).id());
+          }
+        }
+
+        if (isUpToDate) {
+          upToDateAncestors.add(changeData.getId());
+          continue;
+        }
+        ancestorsAreUpToDate = false;
+        bu.addOp(revRsrc.getChange().getId(), rebaseOp);
+        rebaseOps.put(revRsrc.getChange().getId(), rebaseOp);
+      }
+
+      if (ancestorsAreUpToDate) {
+        throw new ResourceConflictException("The whole chain is already up to date.");
+      }
+
+      bu.setNotify(NotifyResolver.Result.none());
+      bu.setRepository(repo, rw, oi);
+      bu.execute();
+    }
+
+    RebaseChainInfo res = new RebaseChainInfo();
+    res.rebasedChanges = new ArrayList<>();
+    ChangeJson changeJson = json.create(OPTIONS);
+    for (Change.Id c : upToDateAncestors) {
+      res.rebasedChanges.add(changeJson.format(project, c));
+    }
+    for (Map.Entry<Change.Id, RebaseChangeOp> e : rebaseOps.entrySet()) {
+      Change.Id id = e.getKey();
+      RebaseChangeOp op = e.getValue();
+      ChangeInfo changeInfo = changeJson.format(project, id);
+      changeInfo.containsGitConflicts =
+          !op.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+      res.rebasedChanges.add(changeInfo);
+    }
+    if (res.rebasedChanges.stream()
+        .anyMatch(i -> i.containsGitConflicts != null && i.containsGitConflicts)) {
+      res.containsGitConflicts = true;
+    }
+    return Response.ok(res);
+  }
+
+  @Override
+  public Description getDescription(ChangeResource tipRsrc) throws Exception {
+    UiAction.Description description =
+        new UiAction.Description()
+            .setLabel("Rebase Chain")
+            .setTitle(
+                "Rebase the ancestry chain onto the tip of the target branch. Makes you the "
+                    + "uploader of the changes which can affect validity of approvals.")
+            .setVisible(false);
+
+    Change tip = tipRsrc.getChange();
+    if (!tip.isNew()) {
+      return description;
+    }
+    if (!projectCache
+        .get(tipRsrc.getProject())
+        .orElseThrow(illegalState(tipRsrc.getProject()))
+        .statePermitsWrite()) {
+      return description;
+    }
+
+    if (patchSetUtil.isPatchSetLocked(tipRsrc.getNotes())) {
+      return description;
+    }
+
+    boolean visible = true;
+    boolean enabled = true;
+    try (Repository repo = repoManager.openRepository(tipRsrc.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+      PatchSetData oldestAncestor = chain.get(0);
+      if (rebaseUtil.canRebase(
+          oldestAncestor.patchSet(), oldestAncestor.data().change().getDest(), repo, rw)) {
+        enabled = false;
+      }
+
+      for (PatchSetData ps : chain) {
+        RevisionResource psRsrc =
+            new RevisionResource(
+                changeResourceFactory.create(ps.data(), tipRsrc.getUser()), ps.patchSet());
+
+        if (!psRsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
+          visible = false;
+          break;
+        }
+
+        if (patchSetUtil.isPatchSetLocked(psRsrc.getNotes())) {
+          enabled = false;
+        }
+        if (!RebaseUtil.hasOneParent(rw, psRsrc.getPatchSet())) {
+          enabled = false;
+        }
+      }
+    }
+    return description.setVisible(visible).setEnabled(enabled);
+  }
+
+  private ObjectId currentBase(RevWalk rw, PatchSet ps) throws IOException {
+    return rw.parseCommit(ps.commitId()).getParent(0);
+  }
+
+  private List<PatchSetData> getChainForCurrentPatchSet(ChangeResource rsrc)
+      throws PermissionBackendException, IOException {
+    return Lists.reverse(
+        getRelatedChangesUtil.getAncestors(
+            changeDataFactory.create(rsrc.getNotes()),
+            patchSetUtil.current(rsrc.getNotes()),
+            true));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index c39b1f4..17fc6db 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -17,8 +17,6 @@
 import static com.google.gerrit.entities.RefNames.isConfigRef;
 
 import com.google.common.base.Strings;
-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.BranchInfo;
@@ -32,6 +30,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -48,7 +47,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Map;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -160,7 +158,7 @@
           rsrc.getName(),
           identifiedUser.get(),
           u,
-          getValidateOptionsAsMultimap(input.validationOptions));
+          ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
       RefUpdate.Result result = u.update(rw);
       switch (result) {
         case FAST_FORWARD:
@@ -220,18 +218,4 @@
   private boolean isBranchAllowed(String branch) {
     return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
   }
-
-  private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
-      @Nullable Map<String, String> validationOptions) {
-    if (validationOptions == null) {
-      return ImmutableListMultimap.of();
-    }
-
-    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
-        ImmutableListMultimap.builder();
-    validationOptions
-        .entrySet()
-        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
-    return validationOptionsBuilder.build();
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index ac45a58..9feff55 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,7 +47,6 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -62,7 +61,6 @@
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
-import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheStats;
@@ -118,9 +116,7 @@
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
@@ -148,21 +144,17 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.events.AttentionSetListener;
-import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 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.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
@@ -170,11 +162,7 @@
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
-import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.ChangeMessageModifier;
-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.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -196,7 +184,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.text.MessageFormat;
@@ -218,7 +205,6 @@
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
@@ -779,366 +765,6 @@
     assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + changeId);
   }
 
-  @FunctionalInterface
-  private interface Rebase {
-    void call(String id) throws RestApiException;
-  }
-
-  @Test
-  public void rebaseViaRevisionApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).current().rebase());
-  }
-
-  @Test
-  public void rebaseViaChangeApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).rebase());
-  }
-
-  private void testRebase(Rebase rebase) throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Add an approval whose score should be copied on trivial rebase
-    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
-
-    String changeId = r2.getChangeId();
-    // Rebase the second change
-    rebase.call(changeId);
-
-    // Second change should have 2 patch sets and an approval
-    ChangeInfo c2 = gApi.changes().id(changeId).get(CURRENT_REVISION, DETAILED_LABELS);
-    assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
-
-    // ...and the committer and description should be correct
-    ChangeInfo info = gApi.changes().id(changeId).get(CURRENT_REVISION, CURRENT_COMMIT);
-    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
-    assertThat(committer.name).isEqualTo(admin.fullName());
-    assertThat(committer.email).isEqualTo(admin.email());
-    String description = info.revisions.get(info.currentRevision).description;
-    assertThat(description).isEqualTo("Rebase");
-
-    // ...and the approval was copied
-    LabelInfo cr = c2.labels.get(LabelId.CODE_REVIEW);
-    assertThat(cr).isNotNull();
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).value).isEqualTo(1);
-
-    // Rebasing the second change again should fail
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().rebase());
-    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
-  }
-
-  @Test
-  public void rebaseAsUploaderInAttentionSet() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    TestAccount admin2 = accountCreator.admin2();
-    requestScopeOperations.setApiUser(admin2.id());
-    amendChangeWithUploader(r2, project, admin2);
-    gApi.changes()
-        .id(r2.getChangeId())
-        .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
-
-    gApi.changes().id(r2.getChangeId()).rebase();
-  }
-
-  @Test
-  public void rebaseOnChangeNumber() throws Exception {
-    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
-    Change.Id id1 = r1.getChange().getId();
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r2.getChangeId()).rebase(in);
-
-    Change.Id id2 = r2.getChange().getId();
-    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    List<RelatedChangeAndCommitInfo> related =
-        gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
-    assertThat(related).hasSize(2);
-    assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
-    assertThat(related.get(0)._revisionNumber).isEqualTo(2);
-    assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
-    assertThat(related.get(1)._revisionNumber).isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseOnClosedChange() throws Exception {
-    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
-    // Submit first change.
-    Change.Id id1 = r1.getChange().getId();
-    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id1.get()).current().submit();
-
-    // Rebase second change on first change.
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r2.getChangeId()).rebase(in);
-
-    Change.Id id2 = r2.getChange().getId();
-    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
-  }
-
-  @Test
-  public void rebaseOnNonExistingChange() throws Exception {
-    String changeId = createChange().getChangeId();
-    RebaseInput in = new RebaseInput();
-    in.base = "999999";
-    UnprocessableEntityException exception =
-        assertThrows(
-            UnprocessableEntityException.class, () -> gApi.changes().id(changeId).rebase(in));
-    assertThat(exception).hasMessageThat().isEqualTo("Base change not found: " + in.base);
-  }
-
-  @Test
-  public void rebaseFromRelationChainToClosedChange() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-
-    createChange();
-    PushOneCommit.Result r3 = createChange();
-
-    // Submit first change.
-    Change.Id id1 = r1.getChange().getId();
-    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id1.get()).current().submit();
-
-    // Rebase third change on first change.
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r3.getChangeId()).rebase(in);
-
-    Change.Id id3 = r3.getChange().getId();
-    ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
-    assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseAllowedWithPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).rebase();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
-        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseWithValidationOptions() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    RebaseInput rebaseInput = new RebaseInput();
-    rebaseInput.validationOptions = ImmutableMap.of("key", "value");
-
-    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
-      // Rebase the second change
-      gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput);
-      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
-          .containsExactly("key", "value");
-    }
-  }
-
-  @Test
-  public void rebaseOutdatedPatchSet() throws Exception {
-    String fileName1 = "a.txt";
-    String fileContent1 = "some content";
-    String fileName2 = "b.txt";
-    String fileContent2Ps1 = "foo";
-    String fileContent2Ps2 = "foo/bar";
-
-    // Create two changes both with the same parent touching disjunct files
-    PushOneCommit.Result r =
-        pushFactory
-            .create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName1, fileContent1)
-            .to("refs/for/master");
-    r.assertOkStatus();
-    String changeId1 = r.getChangeId();
-    testRepo.reset("HEAD~1");
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName2, fileContent2Ps1);
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    r2.assertOkStatus();
-    String changeId2 = r2.getChangeId();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(changeId1).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Amend the second change so that it has 2 patch sets
-    amendChange(
-            changeId2,
-            "refs/for/master",
-            admin,
-            testRepo,
-            PushOneCommit.SUBJECT,
-            fileName2,
-            fileContent2Ps2)
-        .assertOkStatus();
-    ChangeInfo changeInfo2 = gApi.changes().id(changeId2).get();
-    assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(2);
-
-    // Rebase the first patch set of the second change
-    gApi.changes().id(changeId2).revision(1).rebase();
-
-    // Second change should have 3 patch sets
-    changeInfo2 = gApi.changes().id(changeId2).get();
-    assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(3);
-
-    // ... and the committer and description should be correct
-    ChangeInfo info = gApi.changes().id(changeId2).get(CURRENT_REVISION, CURRENT_COMMIT);
-    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
-    assertThat(committer.name).isEqualTo(admin.fullName());
-    assertThat(committer.email).isEqualTo(admin.email());
-    String description = info.revisions.get(info.currentRevision).description;
-    assertThat(description).isEqualTo("Rebase");
-
-    // ... and the file contents should match with patch set 1 based on change1
-    assertThat(gApi.changes().id(changeId2).current().file(fileName1).content().asString())
-        .isEqualTo(fileContent1);
-    assertThat(gApi.changes().id(changeId2).current().file(fileName2).content().asString())
-        .isEqualTo(fileContent2Ps1);
-  }
-
   @Test
   public void deleteNewChangeAsAdmin() throws Exception {
     deleteChangeAsUser(admin, admin);
@@ -1471,166 +1097,6 @@
   }
 
   @Test
-  public void rebaseUpToDateChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase());
-    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
-  }
-
-  @Test
-  public void rebaseConflict() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
-
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "other content",
-            "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    r2.assertOkStatus();
-    ResourceConflictException exception =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
-    assertThat(exception)
-        .hasMessageThat()
-        .isEqualTo(
-            String.format(
-                "The change could not be rebased due to a conflict during merge.\n\n"
-                    + "merge conflict(s):\n%s",
-                PushOneCommit.FILE_NAME));
-  }
-
-  @Test
-  public void rebaseDoesNotAddWorkInProgress() throws Exception {
-    PushOneCommit.Result r = createChange();
-
-    // create an unrelated change so that we can rebase
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result unrelated = createChange();
-    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(unrelated.getChangeId()).current().submit();
-
-    gApi.changes().id(r.getChangeId()).rebase();
-
-    // change is still ready for review after rebase
-    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
-  }
-
-  @Test
-  public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
-    PushOneCommit.Result r = createChange();
-    change(r).setWorkInProgress();
-
-    // create an unrelated change so that we can rebase
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result unrelated = createChange();
-    gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(unrelated.getChangeId()).current().submit();
-
-    gApi.changes().id(r.getChangeId()).rebase();
-
-    // change is still work in progress after rebase
-    assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
-  }
-
-  @Test
-  public void rebaseConflict_conflictsAllowed() throws Exception {
-    String patchSetSubject = "patch set change";
-    String patchSetContent = "patch set content";
-    String baseSubject = "base change";
-    String baseContent = "base content";
-
-    PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
-
-    testRepo.reset("HEAD~1");
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(), testRepo, patchSetSubject, PushOneCommit.FILE_NAME, patchSetContent);
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    r2.assertOkStatus();
-
-    String changeId = r2.getChangeId();
-    RevCommit patchSet = r2.getCommit();
-    RevCommit base = r1.getCommit();
-
-    TestWorkInProgressStateChangedListener wipStateChangedListener =
-        new TestWorkInProgressStateChangedListener();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(wipStateChangedListener)) {
-      RebaseInput rebaseInput = new RebaseInput();
-      rebaseInput.allowConflicts = true;
-      ChangeInfo changeInfo =
-          gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
-      assertThat(changeInfo.containsGitConflicts).isTrue();
-      assertThat(changeInfo.workInProgress).isTrue();
-    }
-    assertThat(wipStateChangedListener.invoked).isTrue();
-    assertThat(wipStateChangedListener.wip).isTrue();
-
-    // To get the revisions, we must retrieve the change with more change options.
-    ChangeInfo changeInfo =
-        gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-    assertThat(changeInfo.revisions).hasSize(2);
-    assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
-        .isEqualTo(base.name());
-
-    // Verify that the file content in the created patch set is correct.
-    // We expect that it has conflict markers to indicate the conflict.
-    BinaryResult bin =
-        gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content();
-    ByteArrayOutputStream os = new ByteArrayOutputStream();
-    bin.writeTo(os);
-    String fileContent = new String(os.toByteArray(), UTF_8);
-    String patchSetSha1 = abbreviateName(patchSet, 6);
-    String baseSha1 = abbreviateName(base, 6);
-    assertThat(fileContent)
-        .isEqualTo(
-            "<<<<<<< PATCH SET ("
-                + patchSetSha1
-                + " "
-                + patchSetSubject
-                + ")\n"
-                + patchSetContent
-                + "\n"
-                + "=======\n"
-                + baseContent
-                + "\n"
-                + ">>>>>>> BASE      ("
-                + baseSha1
-                + " "
-                + baseSubject
-                + ")\n");
-
-    // Verify the message that has been posted on the change.
-    List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
-    assertThat(messages).hasSize(2);
-    assertThat(Iterables.getLast(messages).message)
-        .isEqualTo(
-            "Patch Set 2: Patch Set 1 was rebased\n\n"
-                + "The following files contain Git conflicts:\n"
-                + "* "
-                + PushOneCommit.FILE_NAME
-                + "\n");
-  }
-
-  @Test
   public void attentionSetListener_firesOnChange() throws Exception {
     PushOneCommit.Result r1 = createChange();
     AttentionSetInput addUser = new AttentionSetInput(user.email(), "some reason");
@@ -1663,156 +1129,6 @@
   }
 
   @Test
-  public void rebaseChangeBase() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    PushOneCommit.Result r3 = createChange();
-    RebaseInput ri = new RebaseInput();
-
-    // rebase r3 directly onto master (break dep. towards r2)
-    ri.base = "";
-    gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
-    PatchSet ps3 = r3.getPatchSet();
-    assertThat(ps3.id().get()).isEqualTo(2);
-
-    // rebase r2 onto r3 (referenced by ref)
-    ri.base = ps3.id().toRefName();
-    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
-    PatchSet ps2 = r2.getPatchSet();
-    assertThat(ps2.id().get()).isEqualTo(2);
-
-    // rebase r1 onto r2 (referenced by commit)
-    ri.base = ps2.commitId().name();
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
-    PatchSet ps1 = r1.getPatchSet();
-    assertThat(ps1.id().get()).isEqualTo(2);
-
-    // rebase r1 onto r3 (referenced by change number)
-    ri.base = String.valueOf(r3.getChange().getId().get());
-    gApi.changes().id(r1.getChangeId()).revision(ps1.commitId().name()).rebase(ri);
-    assertThat(r1.getPatchSetId().get()).isEqualTo(3);
-  }
-
-  @Test
-  public void rebaseChangeBaseRecursion() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r2.getCommit().name();
-    String expectedMessage =
-        "base change "
-            + r2.getChangeId()
-            + " is a descendant of the current change - recursion not allowed";
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains(expectedMessage);
-  }
-
-  @Test
-  public void rebaseAbandonedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = info(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).revision(r.getCommit().name()).rebase());
-    assertThat(thrown).hasMessageThat().contains("change is abandoned");
-  }
-
-  @Test
-  public void rebaseOntoAbandonedChange() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Abandon the first change
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = info(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r.getCommit().name();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
-  }
-
-  @Test
-  public void rebaseOntoSelf() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String commit = r.getCommit().name();
-    RebaseInput ri = new RebaseInput();
-    ri.base = commit;
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).revision(commit).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains("cannot rebase change onto itself");
-  }
-
-  @Test
-  public void cannotRebaseOntoBaseThatIsNotPresentInTargetBranch() throws Exception {
-    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-
-    BranchInput branchInput = new BranchInput();
-    branchInput.revision = initial.getName();
-    gApi.projects().name(project.get()).branch("foo").create(branchInput);
-
-    PushOneCommit.Result r1 =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "Change on foo branch", "a.txt", "a-content")
-            .to("refs/for/foo");
-    approve(r1.getChangeId());
-    gApi.changes().id(r1.getChangeId()).current().submit();
-
-    // reset HEAD in order to create a sibling of the first change
-    testRepo.reset(initial);
-
-    PushOneCommit.Result r2 =
-        pushFactory
-            .create(admin.newIdent(), testRepo, "Change on master branch", "b.txt", "b-content")
-            .to("refs/for/master");
-
-    RebaseInput rebaseInput = new RebaseInput();
-    rebaseInput.base = r1.getCommit().getName();
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            String.format(
-                "base change is targeting wrong branch: %s,refs/heads/foo", project.get()));
-
-    rebaseInput.base = "refs/heads/foo";
-    thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            String.format(
-                "base revision is missing from the destination branch: %s", rebaseInput.base));
-  }
-
-  @Test
   @TestProjectInput(createEmptyCommit = false)
   public void changeNoParentToOneParent() throws Exception {
     // create initial commit with no parent and push it as change, so that patch
@@ -5064,19 +4380,6 @@
     void call(String changeId, String reviewer) throws RestApiException;
   }
 
-  private static class TestWorkInProgressStateChangedListener
-      implements WorkInProgressStateChangedListener {
-    boolean invoked;
-    Boolean wip;
-
-    @Override
-    public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
-      this.invoked = true;
-      this.wip =
-          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
-    }
-  }
-
   public static class TestAttentionSetListenerModule extends AbstractModule {
     @Override
     public void configure() {
@@ -5101,15 +4404,4 @@
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
-
-  private static class TestCommitValidationListener implements CommitValidationListener {
-    public CommitReceivedEvent receiveEvent;
-
-    @Override
-    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
-        throws CommitValidationException {
-      this.receiveEvent = receiveEvent;
-      return ImmutableList.of();
-    }
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
new file mode 100644
index 0000000..4248ac5
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -0,0 +1,1128 @@
+// Copyright (C) 2022 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.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+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.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+  RebaseIT.RebaseViaRevisionApi.class, //
+  RebaseIT.RebaseViaChangeApi.class, //
+  RebaseIT.RebaseChain.class, //
+})
+public class RebaseIT {
+  public abstract static class Base extends AbstractDaemonTest {
+    @Inject protected RequestScopeOperations requestScopeOperations;
+    @Inject protected ProjectOperations projectOperations;
+    @Inject protected ExtensionRegistry extensionRegistry;
+
+    @FunctionalInterface
+    protected interface RebaseCall {
+      void call(String id) throws RestApiException;
+    }
+
+    @FunctionalInterface
+    protected interface RebaseCallWithInput {
+      void call(String id, RebaseInput in) throws RestApiException;
+    }
+
+    protected RebaseCall rebaseCall;
+    protected RebaseCallWithInput rebaseCallWithInput;
+
+    protected void init(RebaseCall call, RebaseCallWithInput callWithInput) {
+      this.rebaseCall = call;
+      this.rebaseCallWithInput = callWithInput;
+    }
+
+    @Test
+    public void rebaseChange() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the second change
+      rebaseCall.call(r2.getChangeId());
+
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2);
+
+      // Rebasing the second change again should fail
+      verifyChangeIsUpToDate(r2);
+    }
+
+    @Test
+    public void rebaseAbandonedChange() throws Exception {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo info = info(changeId);
+      assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("Change " + r.getChange().getId() + " is abandoned");
+    }
+
+    @Test
+    public void rebaseOntoAbandonedChange() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Abandon the first change
+      String changeId = r.getChangeId();
+      assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo info = info(changeId);
+      assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r.getCommit().name();
+
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
+    }
+
+    @Test
+    public void rebaseOntoSelf() throws Exception {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String commit = r.getCommit().name();
+      RebaseInput ri = new RebaseInput();
+      ri.base = commit;
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class, () -> rebaseCallWithInput.call(changeId, ri));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("cannot rebase change " + r.getChange().getId() + " onto itself");
+    }
+
+    @Test
+    public void rebaseChangeBaseRecursion() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r2.getCommit().name();
+      String expectedMessage =
+          "base change "
+              + r2.getChangeId()
+              + " is a descendant of the current change - recursion not allowed";
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r1.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains(expectedMessage);
+    }
+
+    @Test
+    public void cannotRebaseOntoBaseThatIsNotPresentInTargetBranch() throws Exception {
+      ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+      BranchInput branchInput = new BranchInput();
+      branchInput.revision = initial.getName();
+      gApi.projects().name(project.get()).branch("foo").create(branchInput);
+
+      PushOneCommit.Result r1 =
+          pushFactory
+              .create(admin.newIdent(), testRepo, "Change on foo branch", "a.txt", "a-content")
+              .to("refs/for/foo");
+      approve(r1.getChangeId());
+      gApi.changes().id(r1.getChangeId()).current().submit();
+
+      // reset HEAD in order to create a sibling of the first change
+      testRepo.reset(initial);
+
+      PushOneCommit.Result r2 =
+          pushFactory
+              .create(admin.newIdent(), testRepo, "Change on master branch", "b.txt", "b-content")
+              .to("refs/for/master");
+
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.base = r1.getCommit().getName();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "base change is targeting wrong branch: %s,refs/heads/foo", project.get()));
+
+      rebaseInput.base = "refs/heads/foo";
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "base revision is missing from the destination branch: %s", rebaseInput.base));
+    }
+
+    @Test
+    public void rebaseUpToDateChange() throws Exception {
+      PushOneCommit.Result r = createChange();
+      verifyChangeIsUpToDate(r);
+    }
+
+    @Test
+    public void rebaseDoesNotAddWorkInProgress() throws Exception {
+      PushOneCommit.Result r = createChange();
+
+      // create an unrelated change so that we can rebase
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result unrelated = createChange();
+      gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+      rebaseCall.call(r.getChangeId());
+
+      // change is still ready for review after rebase
+      assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
+    }
+
+    @Test
+    public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
+      PushOneCommit.Result r = createChange();
+      change(r).setWorkInProgress();
+
+      // create an unrelated change so that we can rebase
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result unrelated = createChange();
+      gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+      gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+      rebaseCall.call(r.getChangeId());
+
+      // change is still work in progress after rebase
+      assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+    }
+
+    @Test
+    public void rebaseAsUploaderInAttentionSet() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      TestAccount admin2 = accountCreator.admin2();
+      requestScopeOperations.setApiUser(admin2.id());
+      amendChangeWithUploader(r2, project, admin2);
+      gApi.changes()
+          .id(r2.getChangeId())
+          .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
+
+      rebaseCall.call(r2.getChangeId());
+    }
+
+    @Test
+    public void rebaseOnChangeNumber() throws Exception {
+      String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+      Change.Id id1 = r1.getChange().getId();
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r2.getChangeId(), in);
+
+      Change.Id id2 = r2.getChange().getId();
+      ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      List<RelatedChangeAndCommitInfo> related =
+          gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
+      assertThat(related).hasSize(2);
+      assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
+      assertThat(related.get(0)._revisionNumber).isEqualTo(2);
+      assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
+      assertThat(related.get(1)._revisionNumber).isEqualTo(1);
+    }
+
+    @Test
+    public void rebaseOnClosedChange() throws Exception {
+      String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+      // Submit first change.
+      Change.Id id1 = r1.getChange().getId();
+      gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(id1.get()).current().submit();
+
+      // Rebase second change on first change.
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r2.getChangeId(), in);
+
+      Change.Id id2 = r2.getChange().getId();
+      ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
+    }
+
+    @Test
+    public void rebaseOnNonExistingChange() throws Exception {
+      String changeId = createChange().getChangeId();
+      RebaseInput in = new RebaseInput();
+      in.base = "999999";
+      UnprocessableEntityException exception =
+          assertThrows(
+              UnprocessableEntityException.class, () -> rebaseCallWithInput.call(changeId, in));
+      assertThat(exception).hasMessageThat().contains("Base change not found: " + in.base);
+    }
+
+    @Test
+    public void rebaseNotAllowedWithoutPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+    }
+
+    @Test
+    public void rebaseAllowedWithPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      rebaseCall.call(changeId);
+    }
+
+    @Test
+    public void rebaseNotAllowedWithoutPushPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+    }
+
+    @Test
+    public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+    }
+
+    @Test
+    public void rebaseWithValidationOptions() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.validationOptions = ImmutableMap.of("key", "value");
+
+      TestCommitValidationListener testCommitValidationListener =
+          new TestCommitValidationListener();
+      try (ExtensionRegistry.Registration unusedRegistration =
+          extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+        // Rebase the second change
+        rebaseCallWithInput.call(r2.getChangeId(), rebaseInput);
+        assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+            .containsExactly("key", "value");
+      }
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId, Change.Id baseChangeId, boolean shouldHaveApproval)
+        throws RestApiException {
+      verifyRebaseForChange(changeId, baseChangeId, shouldHaveApproval, 2);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId,
+        Change.Id baseChangeId,
+        boolean shouldHaveApproval,
+        int expectedNumRevisions)
+        throws RestApiException {
+      ChangeInfo baseInfo = gApi.changes().id(baseChangeId.get()).get(CURRENT_REVISION);
+      verifyRebaseForChange(
+          changeId, baseInfo.currentRevision, shouldHaveApproval, expectedNumRevisions);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId, String baseCommit, boolean shouldHaveApproval, int expectedNumRevisions)
+        throws RestApiException {
+      ChangeInfo info =
+          gApi.changes().id(changeId.get()).get(CURRENT_REVISION, CURRENT_COMMIT, DETAILED_LABELS);
+
+      RevisionInfo r = info.revisions.get(info.currentRevision);
+      assertThat(r._number).isEqualTo(expectedNumRevisions);
+
+      // ...and the base should be correct
+      assertThat(r.commit.parents).hasSize(1);
+      assertWithMessage("base commit for change " + changeId)
+          .that(r.commit.parents.get(0).commit)
+          .isEqualTo(baseCommit);
+
+      // ...and the committer and description should be correct
+      GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+      assertThat(committer.name).isEqualTo(admin.fullName());
+      assertThat(committer.email).isEqualTo(admin.email());
+      String description = info.revisions.get(info.currentRevision).description;
+      assertThat(description).isEqualTo("Rebase");
+
+      if (shouldHaveApproval) {
+        // ...and the approval was copied
+        LabelInfo cr = info.labels.get(LabelId.CODE_REVIEW);
+        assertThat(cr).isNotNull();
+        assertThat(cr.all).isNotNull();
+        assertThat(cr.all).hasSize(1);
+        assertThat(cr.all.get(0).value).isEqualTo(1);
+      }
+    }
+
+    protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+      assertThat(thrown).hasMessageThat().contains("Change is already up to date");
+    }
+
+    protected static class TestCommitValidationListener implements CommitValidationListener {
+      public CommitReceivedEvent receiveEvent;
+
+      @Override
+      public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+          throws CommitValidationException {
+        this.receiveEvent = receiveEvent;
+        return ImmutableList.of();
+      }
+    }
+
+    protected static class TestWorkInProgressStateChangedListener
+        implements WorkInProgressStateChangedListener {
+      boolean invoked;
+      Boolean wip;
+
+      @Override
+      public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
+        this.invoked = true;
+        this.wip =
+            event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+      }
+    }
+  }
+
+  public abstract static class Rebase extends Base {
+    @Test
+    public void rebaseChangeBase() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      RebaseInput ri = new RebaseInput();
+
+      // rebase r3 directly onto master (break dep. towards r2)
+      ri.base = "";
+      rebaseCallWithInput.call(r3.getChangeId(), ri);
+      PatchSet ps3 = r3.getPatchSet();
+      assertThat(ps3.id().get()).isEqualTo(2);
+
+      // rebase r2 onto r3 (referenced by ref)
+      ri.base = ps3.id().toRefName();
+      rebaseCallWithInput.call(r2.getChangeId(), ri);
+      PatchSet ps2 = r2.getPatchSet();
+      assertThat(ps2.id().get()).isEqualTo(2);
+
+      // rebase r1 onto r2 (referenced by commit)
+      ri.base = ps2.commitId().name();
+      rebaseCallWithInput.call(r1.getChangeId(), ri);
+      PatchSet ps1 = r1.getPatchSet();
+      assertThat(ps1.id().get()).isEqualTo(2);
+
+      // rebase r1 onto r3 (referenced by change number)
+      ri.base = String.valueOf(r3.getChange().getId().get());
+      rebaseCallWithInput.call(r1.getChangeId(), ri);
+      assertThat(r1.getPatchSetId().get()).isEqualTo(3);
+    }
+
+    @Test
+    public void rebaseWithConflict_conflictsAllowed() throws Exception {
+      String patchSetSubject = "patch set change";
+      String patchSetContent = "patch set content";
+      String baseSubject = "base change";
+      String baseContent = "base content";
+
+      PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      testRepo.reset("HEAD~1");
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              patchSetSubject,
+              PushOneCommit.FILE_NAME,
+              patchSetContent);
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+
+      String changeId = r2.getChangeId();
+      RevCommit patchSet = r2.getCommit();
+      RevCommit base = r1.getCommit();
+
+      TestWorkInProgressStateChangedListener wipStateChangedListener =
+          new TestWorkInProgressStateChangedListener();
+      try (ExtensionRegistry.Registration registration =
+          extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+        RebaseInput rebaseInput = new RebaseInput();
+        rebaseInput.allowConflicts = true;
+        ChangeInfo changeInfo =
+            gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
+        assertThat(changeInfo.containsGitConflicts).isTrue();
+        assertThat(changeInfo.workInProgress).isTrue();
+      }
+      assertThat(wipStateChangedListener.invoked).isTrue();
+      assertThat(wipStateChangedListener.wip).isTrue();
+
+      // To get the revisions, we must retrieve the change with more change options.
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+      assertThat(changeInfo.revisions).hasSize(2);
+      assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+          .isEqualTo(base.name());
+
+      // Verify that the file content in the created patch set is correct.
+      // We expect that it has conflict markers to indicate the conflict.
+      BinaryResult bin =
+          gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content();
+      ByteArrayOutputStream os = new ByteArrayOutputStream();
+      bin.writeTo(os);
+      String fileContent = new String(os.toByteArray(), UTF_8);
+      String patchSetSha1 = abbreviateName(patchSet, 6);
+      String baseSha1 = abbreviateName(base, 6);
+      assertThat(fileContent)
+          .isEqualTo(
+              "<<<<<<< PATCH SET ("
+                  + patchSetSha1
+                  + " "
+                  + patchSetSubject
+                  + ")\n"
+                  + patchSetContent
+                  + "\n"
+                  + "=======\n"
+                  + baseContent
+                  + "\n"
+                  + ">>>>>>> BASE      ("
+                  + baseSha1
+                  + " "
+                  + baseSubject
+                  + ")\n");
+
+      // Verify the message that has been posted on the change.
+      List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+      assertThat(messages).hasSize(2);
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(
+              "Patch Set 2: Patch Set 1 was rebased\n\n"
+                  + "The following files contain Git conflicts:\n"
+                  + "* "
+                  + PushOneCommit.FILE_NAME
+                  + "\n");
+    }
+
+    @Test
+    public void rebaseWithConflict_conflictsForbidden() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              PushOneCommit.SUBJECT,
+              PushOneCommit.FILE_NAME,
+              "other content",
+              "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      ResourceConflictException exception =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r2.getChangeId()));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Change %s could not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n%s",
+                  r2.getChange().getId(), PushOneCommit.FILE_NAME));
+    }
+
+    @Test
+    public void rebaseFromRelationChainToClosedChange() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+
+      createChange();
+      PushOneCommit.Result r3 = createChange();
+
+      // Submit first change.
+      Change.Id id1 = r1.getChange().getId();
+      gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(id1.get()).current().submit();
+
+      // Rebase third change on first change.
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r3.getChangeId(), in);
+
+      Change.Id id3 = r3.getChange().getId();
+      ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
+      assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
+    }
+  }
+
+  public static class RebaseViaRevisionApi extends Rebase {
+    @Before
+    public void setUp() throws Exception {
+      init(
+          id -> gApi.changes().id(id).current().rebase(),
+          (id, in) -> gApi.changes().id(id).current().rebase(in));
+    }
+
+    @Test
+    public void rebaseOutdatedPatchSet() throws Exception {
+      String fileName1 = "a.txt";
+      String fileContent1 = "some content";
+      String fileName2 = "b.txt";
+      String fileContent2Ps1 = "foo";
+      String fileContent2Ps2 = "foo/bar";
+
+      // Create two changes both with the same parent touching disjunct files
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName1, fileContent1)
+              .to("refs/for/master");
+      r.assertOkStatus();
+      String changeId1 = r.getChangeId();
+      testRepo.reset("HEAD~1");
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName2, fileContent2Ps1);
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      String changeId2 = r2.getChangeId();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(changeId1).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Amend the second change so that it has 2 patch sets
+      amendChange(
+              changeId2,
+              "refs/for/master",
+              admin,
+              testRepo,
+              PushOneCommit.SUBJECT,
+              fileName2,
+              fileContent2Ps2)
+          .assertOkStatus();
+      ChangeInfo changeInfo2 = gApi.changes().id(changeId2).get();
+      assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(2);
+
+      // Rebase the first patch set of the second change
+      gApi.changes().id(changeId2).revision(1).rebase();
+
+      // Second change should have 3 patch sets
+      changeInfo2 = gApi.changes().id(changeId2).get();
+      assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(3);
+
+      // ... and the committer and description should be correct
+      ChangeInfo info = gApi.changes().id(changeId2).get(CURRENT_REVISION, CURRENT_COMMIT);
+      GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+      assertThat(committer.name).isEqualTo(admin.fullName());
+      assertThat(committer.email).isEqualTo(admin.email());
+      String description = info.revisions.get(info.currentRevision).description;
+      assertThat(description).isEqualTo("Rebase");
+
+      // ... and the file contents should match with patch set 1 based on change1
+      assertThat(gApi.changes().id(changeId2).current().file(fileName1).content().asString())
+          .isEqualTo(fileContent1);
+      assertThat(gApi.changes().id(changeId2).current().file(fileName2).content().asString())
+          .isEqualTo(fileContent2Ps1);
+    }
+  }
+
+  public static class RebaseViaChangeApi extends Rebase {
+    @Before
+    public void setUp() throws Exception {
+      init(id -> gApi.changes().id(id).rebase(), (id, in) -> gApi.changes().id(id).rebase(in));
+    }
+  }
+
+  public static class RebaseChain extends Base {
+    @Before
+    public void setUp() throws Exception {
+      init(
+          id -> {
+            Object unused = gApi.changes().id(id).rebaseChain();
+          },
+          (id, in) -> {
+            Object unused = gApi.changes().id(id).rebaseChain(in);
+          });
+    }
+
+    @Override
+    protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+      assertThat(thrown).hasMessageThat().contains("The whole chain is already up to date.");
+    }
+
+    @Test
+    public void rebaseChain() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      //       * r4
+      //         *r5
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      PushOneCommit.Result r4 = createChange();
+      PushOneCommit.Result r5 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+      gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the chain through r4.
+      verifyRebaseChainResponse(
+          gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r2, r3, r4);
+
+      // Only r2, r3 and r4 are rebased.
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2);
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), true);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+
+      // r5 wasn't rebased.
+      ChangeInfo r5info = gApi.changes().id(r5.getChangeId()).get(CURRENT_REVISION);
+      assertThat(r5info.revisions.get(r5info.currentRevision)._number).isEqualTo(1);
+
+      // Rebasing r5
+      verifyRebaseChainResponse(
+          gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r2, r3, r4, r5);
+
+      verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false);
+    }
+
+    @Test
+    public void rebasePartlyOutdatedChain() throws Exception {
+      final String file = "modified_file.txt";
+      final String oldContent = "old content";
+      final String newContent = "new content";
+      // Create changes with the following revision hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3/1    r3/2
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange("original patch-set", file, oldContent);
+      PushOneCommit.Result r4 = createChange();
+      gApi.changes()
+          .id(r3.getChangeId())
+          .edit()
+          .modifyFile(file, RawInputUtil.create(newContent.getBytes(UTF_8)));
+      gApi.changes().id(r3.getChangeId()).edit().publish();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the chain through r4.
+      rebaseCall.call(r4.getChangeId());
+
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), false, 2);
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), false, 3);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      assertThat(gApi.changes().id(r3.getChangeId()).current().file(file).content().asString())
+          .isEqualTo(newContent);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+    }
+
+    @Test
+    public void rebaseChainWithConflicts_conflictsForbidden() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              PushOneCommit.SUBJECT,
+              PushOneCommit.FILE_NAME,
+              "other content",
+              "I0020020020020020020020020020020020020002");
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      PushOneCommit.Result r3 = createChange("refs/for/master");
+      r3.assertOkStatus();
+      ResourceConflictException exception =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r3.getChangeId()).rebaseChain());
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Change %s could not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n%s",
+                  r2.getChange().getId(), PushOneCommit.FILE_NAME));
+    }
+
+    @Test
+    public void rebaseChainWithConflicts_conflictsAllowed() throws Exception {
+      String patchSetSubject = "patch set change";
+      String patchSetContent = "patch set content";
+      String baseSubject = "base change";
+      String baseContent = "base content";
+
+      PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      testRepo.reset("HEAD~1");
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              patchSetSubject,
+              PushOneCommit.FILE_NAME,
+              patchSetContent);
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+
+      String changeWithConflictId = r2.getChangeId();
+      RevCommit patchSet = r2.getCommit();
+      RevCommit base = r1.getCommit();
+      PushOneCommit.Result r3 = createChange("refs/for/master");
+      r3.assertOkStatus();
+
+      TestWorkInProgressStateChangedListener wipStateChangedListener =
+          new TestWorkInProgressStateChangedListener();
+      try (ExtensionRegistry.Registration registration =
+          extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+        RebaseInput rebaseInput = new RebaseInput();
+        rebaseInput.allowConflicts = true;
+        Response<RebaseChainInfo> res =
+            gApi.changes().id(r3.getChangeId()).rebaseChain(rebaseInput);
+        verifyRebaseChainResponse(res, true, r2, r3);
+        RebaseChainInfo rebaseChainInfo = res.value();
+        ChangeInfo changeWithConflictInfo = rebaseChainInfo.rebasedChanges.get(0);
+        assertThat(changeWithConflictInfo.changeId).isEqualTo(r2.getChangeId());
+        assertThat(changeWithConflictInfo.containsGitConflicts).isTrue();
+        assertThat(changeWithConflictInfo.workInProgress).isTrue();
+        ChangeInfo childChangeInfo = rebaseChainInfo.rebasedChanges.get(1);
+        assertThat(childChangeInfo.changeId).isEqualTo(r3.getChangeId());
+        assertThat(childChangeInfo.containsGitConflicts).isTrue();
+        assertThat(childChangeInfo.workInProgress).isTrue();
+      }
+      assertThat(wipStateChangedListener.invoked).isTrue();
+      assertThat(wipStateChangedListener.wip).isTrue();
+
+      // To get the revisions, we must retrieve the change with more change options.
+      ChangeInfo changeInfo =
+          gApi.changes()
+              .id(changeWithConflictId)
+              .get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+      assertThat(changeInfo.revisions).hasSize(2);
+      assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+          .isEqualTo(base.name());
+
+      // Verify that the file content in the created patch set is correct.
+      // We expect that it has conflict markers to indicate the conflict.
+      BinaryResult bin =
+          gApi.changes().id(changeWithConflictId).current().file(PushOneCommit.FILE_NAME).content();
+      ByteArrayOutputStream os = new ByteArrayOutputStream();
+      bin.writeTo(os);
+      String fileContent = new String(os.toByteArray(), UTF_8);
+      String patchSetSha1 = abbreviateName(patchSet, 6);
+      String baseSha1 = abbreviateName(base, 6);
+      assertThat(fileContent)
+          .isEqualTo(
+              "<<<<<<< PATCH SET ("
+                  + patchSetSha1
+                  + " "
+                  + patchSetSubject
+                  + ")\n"
+                  + patchSetContent
+                  + "\n"
+                  + "=======\n"
+                  + baseContent
+                  + "\n"
+                  + ">>>>>>> BASE      ("
+                  + baseSha1
+                  + " "
+                  + baseSubject
+                  + ")\n");
+
+      // Verify the message that has been posted on the change.
+      List<ChangeMessageInfo> messages = gApi.changes().id(changeWithConflictId).messages();
+      assertThat(messages).hasSize(2);
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo(
+              "Patch Set 2: Patch Set 1 was rebased\n\n"
+                  + "The following files contain Git conflicts:\n"
+                  + "* "
+                  + PushOneCommit.FILE_NAME
+                  + "\n");
+    }
+
+    @Test
+    public void rebaseOntoMidChain() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      r.assertOkStatus();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      r2.assertOkStatus();
+      PushOneCommit.Result r3 = createChange();
+      r3.assertOkStatus();
+      PushOneCommit.Result r4 = createChange();
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r3.getCommit().name();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r4.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains("recursion not allowed");
+    }
+
+    private void verifyRebaseChainResponse(
+        Response<RebaseChainInfo> res,
+        boolean shouldHaveConflicts,
+        PushOneCommit.Result... changes) {
+      assertThat(res.statusCode()).isEqualTo(200);
+      RebaseChainInfo info = res.value();
+      assertThat(info.rebasedChanges.stream().map(c -> c._number).collect(Collectors.toList()))
+          .containsExactlyElementsIn(
+              Arrays.stream(changes)
+                  .map(c -> c.getChange().getId().get())
+                  .collect(Collectors.toList()))
+          .inOrder();
+      assertThat(info.containsGitConflicts).isEqualTo(shouldHaveConflicts ? true : null);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 81c098f..aeebc10 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -234,11 +234,11 @@
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
         change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().name()
-            + ": The change could not be rebased due to a conflict during merge.\n\n"
-            + "merge conflict(s):\n"
-            + "a.txt");
+        String.format(
+            "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+                + "merge conflict(s):\n"
+                + "a.txt",
+            change2.getCommit().name(), change2.getChange().getId()));
     RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
@@ -362,12 +362,11 @@
 
     submitWithConflict(
         change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().getName()
-            + ": "
-            + "The change could not be rebased due to a conflict during merge.\n\n"
-            + "merge conflict(s):\n"
-            + "fileName 2");
+        String.format(
+            "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+                + "merge conflict(s):\n"
+                + "fileName 2",
+            change2.getCommit().name(), change2.getChange().getId()));
     assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }