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);
}