Add rejectEmptyCommit project config
If a change is identified as the root cause of a problem, different users sometimes create reverts independently and try to submit them. The first revert merges cleanly and reverts the problematic code. The second revert rebases cleanly, but results in an empty commit that is then merged. Some users don't want empty commits in their project. This commit adds a project config to prevent empty commits as a result of merging changes in Gerrit. The UI will be adapted in a later commit to allow easy modifications of the new config option. Change-Id: Ied0c501a6cb8963328440074529834cb43e96439
This commit is contained in:
parent
93f9809f84
commit
dc285c7b46
@ -236,6 +236,10 @@ author last committed. Valid values are 'true', 'false', or 'INHERIT'. The defau
|
||||
This option only takes effect in submit strategies which already modify the commit, i.e.
|
||||
Cherry Pick, Rebase Always, and (perhaps) Rebase If Necessary.
|
||||
|
||||
- 'rejectEmptyCommit': Defines whether empty commits should be rejected when a change is merged.
|
||||
Changes might not seem empty at first but when attempting to merge, rebasing can lead to an empty
|
||||
commit. If this option is set to 'true' the merge would fail.
|
||||
|
||||
Merge strategy
|
||||
|
||||
|
||||
|
@ -2872,6 +2872,9 @@ entities.
|
||||
|`actions` |optional|
|
||||
Actions the caller might be able to perform on this project. The
|
||||
information is a map of view names to
|
||||
|`reject_empty_commit` |optional|
|
||||
link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
|
||||
empty commits should be rejected when a change is merged.
|
||||
link:rest-api-changes.html#action-info[ActionInfo] entities.
|
||||
|=======================================================
|
||||
|
||||
@ -2936,6 +2939,10 @@ If not set, the project state is not updated.
|
||||
|`plugin_config_values` |optional|
|
||||
Plugin configuration values as map which maps the plugin name to a map
|
||||
of parameter names to values.
|
||||
|`reject_empty_commit` |optional|
|
||||
Whether empty commits should be rejected when a change is merged.
|
||||
Can be `TRUE`, `FALSE` or `INHERIT`. +
|
||||
If not set, this setting is not updated.
|
||||
|======================================================
|
||||
|
||||
[[config-parameter-info]]
|
||||
@ -3269,6 +3276,9 @@ Common unit suffixes of 'k', 'm', or 'g' are supported.
|
||||
|`plugin_config_values` |optional|
|
||||
Plugin configuration values as map which maps the plugin name to a map
|
||||
of parameter names to values.
|
||||
|`reject_empty_commit` |optional|
|
||||
Whether empty commits should be rejected when a change is merged
|
||||
(`TRUE`, `FALSE`, `INHERIT`).
|
||||
|=========================================
|
||||
|
||||
[[project-parent-input]]
|
||||
|
@ -436,6 +436,7 @@ public abstract class AbstractDaemonTest {
|
||||
in.useContentMerge = ann.useContributorAgreements();
|
||||
in.useSignedOffBy = ann.useSignedOffBy();
|
||||
in.useContentMerge = ann.useContentMerge();
|
||||
in.rejectEmptyCommit = ann.rejectEmptyCommit();
|
||||
} else {
|
||||
// Defaults should match TestProjectConfig, omitting nullable values.
|
||||
in.createEmptyCommit = true;
|
||||
|
@ -45,6 +45,8 @@ public @interface TestProjectInput {
|
||||
|
||||
InheritableBoolean requireChangeId() default InheritableBoolean.INHERIT;
|
||||
|
||||
InheritableBoolean rejectEmptyCommit() default InheritableBoolean.INHERIT;
|
||||
|
||||
// Fields specific to acceptance test behavior.
|
||||
|
||||
/** Username to use for initial clone, passed to {@link AccountCreator}. */
|
||||
|
@ -35,6 +35,7 @@ public class ConfigInfo {
|
||||
public InheritedBooleanInfo privateByDefault;
|
||||
public InheritedBooleanInfo enableReviewerByEmail;
|
||||
public InheritedBooleanInfo matchAuthorToCommitterDate;
|
||||
public InheritedBooleanInfo rejectEmptyCommit;
|
||||
|
||||
public MaxObjectSizeLimitInfo maxObjectSizeLimit;
|
||||
public SubmitType submitType;
|
||||
|
@ -32,6 +32,7 @@ public class ConfigInput {
|
||||
public InheritableBoolean privateByDefault;
|
||||
public InheritableBoolean enableReviewerByEmail;
|
||||
public InheritableBoolean matchAuthorToCommitterDate;
|
||||
public InheritableBoolean rejectEmptyCommit;
|
||||
public String maxObjectSizeLimit;
|
||||
public SubmitType submitType;
|
||||
public ProjectState state;
|
||||
|
@ -33,6 +33,7 @@ public class ProjectInput {
|
||||
public InheritableBoolean useContentMerge;
|
||||
public InheritableBoolean requireChangeId;
|
||||
public InheritableBoolean createNewChangeForAllNotInTarget;
|
||||
public InheritableBoolean rejectEmptyCommit;
|
||||
public String maxObjectSizeLimit;
|
||||
public Map<String, Map<String, ConfigValue>> pluginConfigValues;
|
||||
}
|
||||
|
@ -39,7 +39,8 @@ public enum BooleanProjectConfig {
|
||||
REJECT_IMPLICIT_MERGES("receive", "rejectImplicitMerges"),
|
||||
PRIVATE_BY_DEFAULT("change", "privateByDefault"),
|
||||
ENABLE_REVIEWER_BY_EMAIL("reviewer", "enableByEmail"),
|
||||
MATCH_AUTHOR_TO_COMMITTER_DATE("project", "matchAuthorToCommitterDate");
|
||||
MATCH_AUTHOR_TO_COMMITTER_DATE("project", "matchAuthorToCommitterDate"),
|
||||
REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit");
|
||||
|
||||
// Git config
|
||||
private final String section;
|
||||
|
@ -15,10 +15,12 @@
|
||||
package com.google.gerrit.server.git.strategy;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
import static com.google.gerrit.server.git.strategy.CommitMergeStatus.EMPTY_COMMIT;
|
||||
import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.gerrit.extensions.restapi.MergeConflictException;
|
||||
import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
|
||||
import com.google.gerrit.reviewdb.client.PatchSet;
|
||||
import com.google.gerrit.reviewdb.client.PatchSetInfo;
|
||||
import com.google.gerrit.server.ChangeUtil;
|
||||
@ -123,6 +125,10 @@ public class CherryPick extends SubmitStrategy {
|
||||
toMerge.setStatusCode(CommitMergeStatus.PATH_CONFLICT);
|
||||
return;
|
||||
} catch (MergeIdenticalTreeException mie) {
|
||||
if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)) {
|
||||
toMerge.setStatusCode(EMPTY_COMMIT);
|
||||
return;
|
||||
}
|
||||
toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
|
||||
return;
|
||||
}
|
||||
|
@ -60,7 +60,12 @@ public enum CommitMergeStatus {
|
||||
NOT_FAST_FORWARD(
|
||||
"Project policy requires all submissions to be a fast-forward.\n"
|
||||
+ "\n"
|
||||
+ "Please rebase the change locally and upload again for review.");
|
||||
+ "Please rebase the change locally and upload again for review."),
|
||||
|
||||
EMPTY_COMMIT(
|
||||
"Change could not be merged because the commit is empty.\n"
|
||||
+ "\n"
|
||||
+ "Project policy requires all commits to contain modifications to at least one file.");
|
||||
|
||||
private final String message;
|
||||
|
||||
|
@ -14,6 +14,9 @@
|
||||
|
||||
package com.google.gerrit.server.git.strategy;
|
||||
|
||||
import static com.google.gerrit.server.git.strategy.CommitMergeStatus.EMPTY_COMMIT;
|
||||
|
||||
import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
|
||||
import com.google.gerrit.server.git.CodeReviewCommit;
|
||||
import com.google.gerrit.server.git.IntegrationException;
|
||||
import com.google.gerrit.server.update.RepoContext;
|
||||
@ -25,6 +28,12 @@ class FastForwardOp extends SubmitStrategyOp {
|
||||
|
||||
@Override
|
||||
protected void updateRepoImpl(RepoContext ctx) throws IntegrationException {
|
||||
if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
|
||||
&& toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
|
||||
toMerge.setStatusCode(EMPTY_COMMIT);
|
||||
return;
|
||||
}
|
||||
|
||||
args.mergeTip.moveTipTo(toMerge, toMerge);
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,9 @@
|
||||
|
||||
package com.google.gerrit.server.git.strategy;
|
||||
|
||||
import static com.google.gerrit.server.git.strategy.CommitMergeStatus.EMPTY_COMMIT;
|
||||
|
||||
import com.google.gerrit.reviewdb.client.BooleanProjectConfig;
|
||||
import com.google.gerrit.server.git.CodeReviewCommit;
|
||||
import com.google.gerrit.server.git.IntegrationException;
|
||||
import com.google.gerrit.server.update.RepoContext;
|
||||
@ -47,6 +50,11 @@ class MergeOneOp extends SubmitStrategyOp {
|
||||
args.destBranch,
|
||||
args.mergeTip.getCurrentTip(),
|
||||
toMerge);
|
||||
if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
|
||||
&& merged.getTree().equals(merged.getParent(0).getTree())) {
|
||||
toMerge.setStatusCode(EMPTY_COMMIT);
|
||||
return;
|
||||
}
|
||||
args.mergeTip.moveTipTo(amendGitlink(merged), toMerge);
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,7 @@
|
||||
package com.google.gerrit.server.git.strategy;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkState;
|
||||
import static com.google.gerrit.server.git.strategy.CommitMergeStatus.EMPTY_COMMIT;
|
||||
import static com.google.gerrit.server.git.strategy.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
@ -124,6 +125,12 @@ public class RebaseSubmitStrategy extends SubmitStrategy {
|
||||
if (args.mergeUtil.canFastForward(
|
||||
args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
|
||||
if (!rebaseAlways) {
|
||||
if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
|
||||
&& toMerge.getTree().equals(toMerge.getParent(0).getTree())) {
|
||||
toMerge.setStatusCode(EMPTY_COMMIT);
|
||||
return;
|
||||
}
|
||||
|
||||
args.mergeTip.moveTipTo(amendGitlink(toMerge), toMerge);
|
||||
toMerge.setStatusCode(CommitMergeStatus.CLEAN_MERGE);
|
||||
acceptMergeTip(args.mergeTip);
|
||||
@ -192,6 +199,11 @@ public class RebaseSubmitStrategy extends SubmitStrategy {
|
||||
newCommit = args.rw.parseCommit(rebaseOp.getRebasedCommit());
|
||||
newPatchSetId = rebaseOp.getPatchSetId();
|
||||
}
|
||||
if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT)
|
||||
&& newCommit.getTree().equals(newCommit.getParent(0).getTree())) {
|
||||
toMerge.setStatusCode(EMPTY_COMMIT);
|
||||
return;
|
||||
}
|
||||
newCommit = amendGitlink(newCommit);
|
||||
newCommit.copyFrom(toMerge);
|
||||
newCommit.setPatchsetId(newPatchSetId);
|
||||
|
@ -128,6 +128,7 @@ public class SubmitStrategyListener implements BatchUpdateListener {
|
||||
case CANNOT_CHERRY_PICK_ROOT:
|
||||
case CANNOT_REBASE_ROOT:
|
||||
case NOT_FAST_FORWARD:
|
||||
case EMPTY_COMMIT:
|
||||
// TODO(dborowitz): Reformat these messages to be more appropriate for
|
||||
// short problem descriptions.
|
||||
commitStatus.problem(id, CharMatcher.is('\n').collapseFrom(s.getMessage(), ' '));
|
||||
|
@ -65,6 +65,9 @@ public class BooleanProjectConfigTransformations {
|
||||
BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE,
|
||||
new Mapper(
|
||||
i -> i.matchAuthorToCommitterDate, (i, v) -> i.matchAuthorToCommitterDate = v))
|
||||
.put(
|
||||
BooleanProjectConfig.REJECT_EMPTY_COMMIT,
|
||||
new Mapper(i -> i.rejectEmptyCommit, (i, v) -> i.rejectEmptyCommit = v))
|
||||
.build();
|
||||
|
||||
static {
|
||||
|
@ -34,6 +34,7 @@ public class CreateProjectArgs {
|
||||
public InheritableBoolean contentMerge;
|
||||
public InheritableBoolean newChangeForAllNotInTarget;
|
||||
public InheritableBoolean changeIdRequired;
|
||||
public InheritableBoolean rejectEmptyCommit;
|
||||
public boolean createEmptyCommit;
|
||||
public String maxObjectSizeLimit;
|
||||
|
||||
|
@ -197,6 +197,8 @@ public class CreateProject implements RestModifyView<TopLevelResource, ProjectIn
|
||||
input.createNewChangeForAllNotInTarget, InheritableBoolean.INHERIT);
|
||||
args.changeIdRequired =
|
||||
MoreObjects.firstNonNull(input.requireChangeId, InheritableBoolean.INHERIT);
|
||||
args.rejectEmptyCommit =
|
||||
MoreObjects.firstNonNull(input.rejectEmptyCommit, InheritableBoolean.INHERIT);
|
||||
try {
|
||||
args.maxObjectSizeLimit = ProjectConfig.validMaxObjectSizeLimit(input.maxObjectSizeLimit);
|
||||
} catch (ConfigInvalidException e) {
|
||||
@ -287,6 +289,7 @@ public class CreateProject implements RestModifyView<TopLevelResource, ProjectIn
|
||||
BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
|
||||
args.newChangeForAllNotInTarget);
|
||||
newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
|
||||
newProject.setBooleanConfig(BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
|
||||
newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
|
||||
if (args.newParent != null) {
|
||||
newProject.setParentName(args.newParent);
|
||||
|
@ -100,6 +100,9 @@ final class CreateProjectCommand extends SshCommand {
|
||||
@Option(name = "--change-id", usage = "if change-id is required")
|
||||
private InheritableBoolean requireChangeID = InheritableBoolean.INHERIT;
|
||||
|
||||
@Option(name = "--reject-empty-commit", usage = "if empty commits should be rejected on submit")
|
||||
private InheritableBoolean rejectEmptyCommit = InheritableBoolean.INHERIT;
|
||||
|
||||
@Option(
|
||||
name = "--new-change-for-all-not-in-target",
|
||||
usage = "if a new change will be created for every commit not in target branch"
|
||||
@ -201,6 +204,7 @@ final class CreateProjectCommand extends SshCommand {
|
||||
input.branches = branch;
|
||||
input.createEmptyCommit = createEmptyCommit;
|
||||
input.maxObjectSizeLimit = maxObjectSizeLimit;
|
||||
input.rejectEmptyCommit = rejectEmptyCommit;
|
||||
if (pluginConfigValues != null) {
|
||||
input.pluginConfigValues = parsePluginConfigValues(pluginConfigValues);
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ import com.google.gerrit.acceptance.TestAccount;
|
||||
import com.google.gerrit.acceptance.TestProjectInput;
|
||||
import com.google.gerrit.common.TimeUtil;
|
||||
import com.google.gerrit.common.data.Permission;
|
||||
import com.google.gerrit.extensions.api.changes.ChangeApi;
|
||||
import com.google.gerrit.extensions.api.changes.SubmitInput;
|
||||
import com.google.gerrit.extensions.api.projects.BranchInput;
|
||||
import com.google.gerrit.extensions.api.projects.ConfigInput;
|
||||
@ -47,6 +48,7 @@ import com.google.gerrit.extensions.client.InheritableBoolean;
|
||||
import com.google.gerrit.extensions.client.ListChangesOption;
|
||||
import com.google.gerrit.extensions.client.SubmitType;
|
||||
import com.google.gerrit.extensions.common.ChangeInfo;
|
||||
import com.google.gerrit.extensions.common.ChangeInput;
|
||||
import com.google.gerrit.extensions.common.LabelInfo;
|
||||
import com.google.gerrit.extensions.registration.DynamicSet;
|
||||
import com.google.gerrit.extensions.registration.RegistrationHandle;
|
||||
@ -988,6 +990,78 @@ public abstract class AbstractSubmit extends AbstractDaemonTest {
|
||||
assertAuthorAndCommitDateEquals(getRemoteHead());
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
|
||||
public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitAllowed() throws Exception {
|
||||
assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
|
||||
|
||||
PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
|
||||
submit(change.getChangeId());
|
||||
|
||||
ChangeApi revert1 = gApi.changes().id(change.getChangeId()).revert();
|
||||
approve(revert1.id());
|
||||
revert1.current().submit();
|
||||
|
||||
ChangeApi revert2 = gApi.changes().id(change.getChangeId()).revert();
|
||||
approve(revert2.id());
|
||||
revert2.current().submit();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
|
||||
public void submitEmptyCommitPatchSetCanNotFastForward_emptyCommitNotAllowed() throws Exception {
|
||||
assume().that(getSubmitType()).isNotEqualTo(SubmitType.FAST_FORWARD_ONLY);
|
||||
|
||||
PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
|
||||
submit(change.getChangeId());
|
||||
|
||||
ChangeApi revert1 = gApi.changes().id(change.getChangeId()).revert();
|
||||
approve(revert1.id());
|
||||
revert1.current().submit();
|
||||
|
||||
ChangeApi revert2 = gApi.changes().id(change.getChangeId()).revert();
|
||||
approve(revert2.id());
|
||||
|
||||
exception.expect(ResourceConflictException.class);
|
||||
exception.expectMessage(
|
||||
"Change "
|
||||
+ revert2.get()._number
|
||||
+ ": Change could not be merged because the commit is empty. "
|
||||
+ "Project policy requires all commits to contain modifications to at least one file.");
|
||||
revert2.current().submit();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestProjectInput(rejectEmptyCommit = InheritableBoolean.FALSE)
|
||||
public void submitEmptyCommitPatchSetCanFastForward_emptyCommitAllowed() throws Exception {
|
||||
ChangeInput ci = new ChangeInput();
|
||||
ci.subject = "Empty change";
|
||||
ci.project = project.get();
|
||||
ci.branch = "master";
|
||||
ChangeApi change = gApi.changes().create(ci);
|
||||
approve(change.id());
|
||||
change.current().submit();
|
||||
}
|
||||
|
||||
@Test
|
||||
@TestProjectInput(rejectEmptyCommit = InheritableBoolean.TRUE)
|
||||
public void submitEmptyCommitPatchSetCanFastForward_emptyCommitNotAllowed() throws Exception {
|
||||
ChangeInput ci = new ChangeInput();
|
||||
ci.subject = "Empty change";
|
||||
ci.project = project.get();
|
||||
ci.branch = "master";
|
||||
ChangeApi change = gApi.changes().create(ci);
|
||||
approve(change.id());
|
||||
|
||||
exception.expect(ResourceConflictException.class);
|
||||
exception.expectMessage(
|
||||
"Change "
|
||||
+ change.get()._number
|
||||
+ ": Change could not be merged because the commit is empty. "
|
||||
+ "Project policy requires all commits to contain modifications to at least one file.");
|
||||
change.current().submit();
|
||||
}
|
||||
|
||||
private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Exception {
|
||||
for (PushOneCommit.Result change : changes) {
|
||||
try (BatchUpdate bu =
|
||||
|
Loading…
Reference in New Issue
Block a user