ConsistencyChecker: Add a field to check expected merged SHA-1

When closing a change by push, the ref update to advance the branch
(ReceiveCommits.java:574) happens before closing the changes
(ReceiveCommits.java:614). This means in the event of some kinds of
database failures, a change might be merged into the destination
branch but never marked merged, *and* the actual commit SHA-1 that was
pushed might differ from any existing patch set.

Without doing a full walk of history, we have no easy way of
recovering from this situation only by inspecting the repo on the
server. The only way to detect a commit matching the change that
should have been inserted as a new patch set would be to inspect the
Change-Id footers, but we wouldn't know how far back in history to
walk. (This is additionally complicated by the fact that a commit with
the same Change-Id may have been merged in from a different branch.)

Fortunately, in the case we're talking about here, the end user should
have some record of the SHA-1 they're looking for, since they
previously ran "git push" on that SHA-1. So, this change provides a
new field in FixInput, expectMergedAs, to provide that SHA-1 and check
that it was actually the SHA-1 the change was merged as. In the event
that there is no patch set for the change at that SHA-1, one is
created.

Since the check would be potentially too expensive to do without
providing this SHA-1, it is only activated when this fix field is
provided.

Change-Id: Ie96633aae39d22694b2e6add072ce9ee82f1e30b
This commit is contained in:
Dave Borowitz 2015-05-05 14:43:40 -07:00
parent bad53ee23f
commit a828fedfba
5 changed files with 375 additions and 15 deletions

View File

@ -3891,6 +3891,9 @@ link:#fix-change[fix change] endpoint.
|Field Name |Description
|`deletePatchSetIfCommitMissing`|If true, delete patch sets from the
database if they refer to missing commit options.
|`expectMergedAs` |If set, check that the change is
merged into the destination branch as this exact SHA-1. If not, insert
a new patch set referring to this commit.
|==========================
[[git-person-info]]

View File

@ -24,6 +24,7 @@ import com.google.common.base.Function;
import com.google.common.collect.Lists;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.common.ProblemInfo;
import com.google.gerrit.reviewdb.client.Account;
@ -374,6 +375,198 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
assertProblems(c);
}
@Test
public void expectedMergedCommitIsLatestPatchSet() throws Exception {
Change c = insertChange();
c.setStatus(Change.Status.MERGED);
PatchSet ps = insertPatchSet(c);
RevCommit commit = parseCommit(ps);
testRepo.update(c.getDest().get(), commit);
FixInput fix = new FixInput();
fix.expectMergedAs = commit.name();
assertThat(checker.check(c, fix).problems()).isEmpty();
}
@Test
public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
Change c = insertChange();
c.setStatus(Change.Status.MERGED);
PatchSet ps = insertPatchSet(c);
RevCommit commit = parseCommit(ps);
testRepo.update(c.getDest().get(), commit);
FixInput fix = new FixInput();
RevCommit other =
testRepo.commit().message(commit.getFullMessage()).create();
fix.expectMergedAs = other.name();
List<ProblemInfo> problems = checker.check(c, fix).problems();
assertThat(problems).hasSize(1);
ProblemInfo p = problems.get(0);
assertThat(p.message).isEqualTo(
"Expected merged commit " + other.name()
+ " is not merged into destination ref refs/heads/master"
+ " (" + commit.name() + ")");
}
@Test
public void createNewPatchSetForExpectedMergeCommitWithNoChangeId()
throws Exception {
Change c = insertChange();
c.setStatus(Change.Status.MERGED);
RevCommit parent =
testRepo.branch(c.getDest().get()).commit().message("parent").create();
PatchSet ps = insertPatchSet(c);
RevCommit commit = parseCommit(ps);
RevCommit mergedAs = testRepo.commit().parent(parent)
.message(commit.getShortMessage()).create();
testRepo.getRevWalk().parseBody(mergedAs);
assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).isEmpty();
testRepo.update(c.getDest().get(), mergedAs);
assertProblems(c, "Patch set 1 (" + commit.name() + ") is not merged into"
+ " destination ref refs/heads/master (" + mergedAs.name()
+ "), but change status is MERGED");
FixInput fix = new FixInput();
fix.expectMergedAs = mergedAs.name();
List<ProblemInfo> problems = checker.check(c, fix).problems();
assertThat(problems).hasSize(1);
ProblemInfo p = problems.get(0);
assertThat(p.message).isEqualTo(
"No patch set found for merged commit " + mergedAs.name());
assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
assertThat(p.outcome).isEqualTo("Inserted as patch set 2");
c = db.changes().get(c.getId());
PatchSet.Id psId2 = new PatchSet.Id(c.getId(), 2);
assertThat(c.currentPatchSetId()).isEqualTo(psId2);
assertThat(db.patchSets().get(psId2).getRevision().get())
.isEqualTo(mergedAs.name());
assertProblems(c);
}
@Test
public void createNewPatchSetForExpectedMergeCommitWithChangeId()
throws Exception {
Change c = insertChange();
c.setStatus(Change.Status.MERGED);
RevCommit parent =
testRepo.branch(c.getDest().get()).commit().message("parent").create();
PatchSet ps = insertPatchSet(c);
RevCommit commit = parseCommit(ps);
RevCommit mergedAs = testRepo.commit().parent(parent)
.message(commit.getShortMessage() + "\n"
+ "\n"
+ "Change-Id: " + c.getKey().get() + "\n").create();
testRepo.getRevWalk().parseBody(mergedAs);
assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
.containsExactly(c.getKey().get());
testRepo.update(c.getDest().get(), mergedAs);
assertProblems(c, "Patch set 1 (" + commit.name() + ") is not merged into"
+ " destination ref refs/heads/master (" + mergedAs.name()
+ "), but change status is MERGED");
FixInput fix = new FixInput();
fix.expectMergedAs = mergedAs.name();
List<ProblemInfo> problems = checker.check(c, fix).problems();
assertThat(problems).hasSize(1);
ProblemInfo p = problems.get(0);
assertThat(p.message).isEqualTo(
"No patch set found for merged commit " + mergedAs.name());
assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
assertThat(p.outcome).isEqualTo("Inserted as patch set 2");
c = db.changes().get(c.getId());
PatchSet.Id psId2 = new PatchSet.Id(c.getId(), 2);
assertThat(c.currentPatchSetId()).isEqualTo(psId2);
assertThat(db.patchSets().get(psId2).getRevision().get())
.isEqualTo(mergedAs.name());
assertProblems(c);
}
@Test
public void expectedMergedCommitIsOldPatchSetOfSameChange()
throws Exception {
Change c = insertChange();
c.setStatus(Change.Status.MERGED);
PatchSet ps1 = insertPatchSet(c);
String rev1 = ps1.getRevision().get();
incrementPatchSet(c);
PatchSet ps2 = insertPatchSet(c);
testRepo.branch(c.getDest().get()).update(parseCommit(ps1));
FixInput fix = new FixInput();
fix.expectMergedAs = rev1;
List<ProblemInfo> problems = checker.check(c, fix).problems();
assertThat(problems).hasSize(1);
ProblemInfo p = problems.get(0);
assertThat(p.message).isEqualTo(
"Expected merged commit " + rev1 + " corresponds to patch set "
+ ps1.getId() + ", which is not the current patch set " + ps2.getId());
}
@Test
public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
Change c = insertChange();
c.setStatus(Change.Status.MERGED);
RevCommit parent =
testRepo.branch(c.getDest().get()).commit().message("parent").create();
PatchSet ps = insertPatchSet(c);
RevCommit commit = parseCommit(ps);
String badId = "I0000000000000000000000000000000000000000";
RevCommit mergedAs = testRepo.commit().parent(parent)
.message(commit.getShortMessage() + "\n"
+ "\n"
+ "Change-Id: " + badId + "\n")
.create();
testRepo.getRevWalk().parseBody(mergedAs);
testRepo.update(c.getDest().get(), mergedAs);
FixInput fix = new FixInput();
fix.expectMergedAs = mergedAs.name();
List<ProblemInfo> problems = checker.check(c, fix).problems();
assertThat(problems).hasSize(1);
ProblemInfo p = problems.get(0);
assertThat(p.message).isEqualTo(
"Expected merged commit " + mergedAs.name() + " has Change-Id: "
+ badId + ", but expected " + c.getKey().get());
}
@Test
public void expectedMergedCommitMatchesMultiplePatchSets()
throws Exception {
Change c1 = insertChange();
c1.setStatus(Change.Status.MERGED);
insertPatchSet(c1);
RevCommit commit = testRepo.branch(c1.getDest().get()).commit().create();
Change c2 = insertChange();
PatchSet ps2 = newPatchSet(c2.currentPatchSetId(), commit, adminId);
updatePatchSetRef(ps2);
db.patchSets().insert(singleton(ps2));
Change c3 = insertChange();
PatchSet ps3 = newPatchSet(c3.currentPatchSetId(), commit, adminId);
updatePatchSetRef(ps3);
db.patchSets().insert(singleton(ps3));
FixInput fix = new FixInput();
fix.expectMergedAs = commit.name();
List<ProblemInfo> problems = checker.check(c1, fix).problems();
assertThat(problems).hasSize(1);
ProblemInfo p = problems.get(0);
assertThat(p.message).isEqualTo(
"Multiple patch sets for expected merged commit " + commit.name()
+ ": [" + ps2 + ", " + ps3 + "]");
}
private Change insertChange() throws Exception {
Change c = newChange(project, adminId);
db.changes().insert(singleton(c));
@ -388,7 +581,7 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
private PatchSet insertPatchSet(Change c) throws Exception {
db.changes().upsert(singleton(c));
RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
.parent(tip).create();
.parent(tip).message("Change " + c.getId().get()).create();
PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
updatePatchSetRef(ps);
db.patchSets().insert(singleton(ps));
@ -413,6 +606,13 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
}
private RevCommit parseCommit(PatchSet ps) throws Exception {
RevCommit commit = testRepo.getRevWalk()
.parseCommit(ObjectId.fromString(ps.getRevision().get()));
testRepo.getRevWalk().parseBody(commit);
return commit;
}
private void assertProblems(Change c, String... expected) {
assertThat(Lists.transform(checker.check(c).problems(),
new Function<ProblemInfo, String>() {

View File

@ -16,4 +16,6 @@ package com.google.gerrit.extensions.api.changes;
public class FixInput {
public boolean deletePatchSetIfCommitMissing;
public String expectMergedAs;
}

View File

@ -18,10 +18,14 @@ import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
import static com.google.gerrit.server.ChangeUtil.TO_PS_ID;
import com.google.auto.value.AutoValue;
import com.google.common.base.Predicate;
import com.google.common.collect.Collections2;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.collect.MultimapBuilder;
import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.common.ProblemInfo;
@ -29,13 +33,19 @@ import com.google.gerrit.extensions.common.ProblemInfo.Status;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.change.PatchSetInserter.ValidatePolicy;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.patch.PatchSetInfoFactory;
import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.InvalidChangeOperationException;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gwtorm.server.AtomicUpdate;
import com.google.gwtorm.server.OrmException;
@ -61,6 +71,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Checks changes for various kinds of inconsistency and corruption.
@ -94,7 +105,9 @@ public class ConsistencyChecker {
private final GitRepositoryManager repoManager;
private final Provider<CurrentUser> user;
private final Provider<PersonIdent> serverIdent;
private final ChangeControl.GenericFactory changeControlFactory;
private final PatchSetInfoFactory patchSetInfoFactory;
private final PatchSetInserter.Factory patchSetInserterFactory;
private FixInput fix;
private Change change;
@ -113,12 +126,16 @@ public class ConsistencyChecker {
GitRepositoryManager repoManager,
Provider<CurrentUser> user,
@GerritPersonIdent Provider<PersonIdent> serverIdent,
PatchSetInfoFactory patchSetInfoFactory) {
ChangeControl.GenericFactory changeControlFactory,
PatchSetInfoFactory patchSetInfoFactory,
PatchSetInserter.Factory patchSetInserterFactory) {
this.db = db;
this.repoManager = repoManager;
this.user = user;
this.serverIdent = serverIdent;
this.changeControlFactory = changeControlFactory;
this.patchSetInfoFactory = patchSetInfoFactory;
this.patchSetInserterFactory = patchSetInserterFactory;
reset();
}
@ -303,15 +320,20 @@ public class ConsistencyChecker {
if (tip == null) {
return;
}
boolean merged;
try {
merged = rw.isMergedInto(currPsCommit, tip);
} catch (IOException e) {
problem("Error checking whether patch set " + currPs.getId().get()
+ " is merged");
return;
if (fix != null && fix.expectMergedAs != null) {
checkExpectMergedAs();
} else {
boolean merged;
try {
merged = rw.isMergedInto(currPsCommit, tip);
} catch (IOException e) {
problem("Error checking whether patch set " + currPs.getId().get()
+ " is merged");
return;
}
checkMergedBitMatchesStatus(currPs, currPsCommit, merged);
}
checkMergedBitMatchesStatus(currPs, currPsCommit, merged);
}
private void checkMergedBitMatchesStatus(PatchSet ps, RevCommit commit,
@ -333,6 +355,120 @@ public class ConsistencyChecker {
}
}
private void checkExpectMergedAs() {
ObjectId objId =
parseObjectId(fix.expectMergedAs, "expected merged commit");
RevCommit commit = parseCommit(objId, "expected merged commit");
if (commit == null) {
return;
}
if (Objects.equals(commit, currPsCommit)) {
// Caller gave us latest patch set SHA-1; verified in checkPatchSets.
return;
}
try {
if (!rw.isMergedInto(commit, tip)) {
problem(String.format("Expected merged commit %s is not merged into"
+ " destination ref %s (%s)",
commit.name(), change.getDest().get(), tip.name()));
return;
}
RevId revId = new RevId(commit.name());
List<PatchSet> patchSets = FluentIterable
.from(db.get().patchSets().byRevision(revId))
.filter(new Predicate<PatchSet>() {
@Override
public boolean apply(PatchSet ps) {
try {
Change c = db.get().changes().get(ps.getId().getParentKey());
return c != null && c.getDest().equals(change.getDest());
} catch (OrmException e) {
warn(e);
return true; // Should cause an error below, that's good.
}
}
}).toSortedList(ChangeUtil.PS_ID_ORDER);
switch (patchSets.size()) {
case 0:
// No patch set for this commit; insert one.
rw.parseBody(commit);
String changeId = Iterables.getFirst(
commit.getFooterLines(FooterConstants.CHANGE_ID), null);
// Missing Change-Id footer is ok, but mismatched is not.
if (changeId != null && !changeId.equals(change.getKey().get())) {
problem(String.format("Expected merged commit %s has Change-Id: %s,"
+ " but expected %s",
commit.name(), changeId, change.getKey().get()));
return;
}
PatchSet ps = insertPatchSet(commit);
if (ps != null) {
checkMergedBitMatchesStatus(ps, commit, true);
}
break;
case 1:
// Existing patch set of this commit; check that it is the current
// patch set.
// TODO(dborowitz): This could be fixed if it's an older patch set of
// the current change.
PatchSet.Id id = patchSets.get(0).getId();
if (!id.equals(change.currentPatchSetId())) {
problem(String.format("Expected merged commit %s corresponds to"
+ " patch set %s, which is not the current patch set %s",
commit.name(), id, change.currentPatchSetId()));
}
break;
default:
problem(String.format(
"Multiple patch sets for expected merged commit %s: %s",
commit.name(), patchSets));
break;
}
} catch (OrmException | IOException e) {
error("Error looking up expected merged commit " + fix.expectMergedAs,
e);
}
}
private PatchSet insertPatchSet(RevCommit commit) {
ProblemInfo p =
problem("No patch set found for merged commit " + commit.name());
if (!user.get().isIdentifiedUser()) {
p.status = Status.FIX_FAILED;
p.outcome =
"Must be called by an identified user to insert new patch set";
return null;
}
try {
ChangeControl ctl = changeControlFactory.controlFor(change, user.get());
PatchSetInserter inserter =
patchSetInserterFactory.create(repo, rw, ctl, commit);
change = inserter.setValidatePolicy(ValidatePolicy.NONE)
.setRunHooks(false)
.setSendMail(false)
.setAllowClosed(true)
.setUploader(((IdentifiedUser) user.get()).getAccountId())
// TODO: fix setMessage to work without init()
.setMessage(
"Patch set for merged commit inserted by consistency checker")
.insert();
p.status = Status.FIXED;
p.outcome = "Inserted as patch set " + change.currentPatchSetId().get();
return inserter.getPatchSet();
} catch (InvalidChangeOperationException | OrmException | IOException
| NoSuchChangeException e) {
warn(e);
p.status = Status.FIX_FAILED;
p.outcome = "Error inserting new patch set";
return null;
}
}
private void fixMerged(ProblemInfo p) {
try {
change = db.get().changes().atomicUpdate(change.getId(),
@ -486,7 +622,11 @@ public class ConsistencyChecker {
private boolean error(String msg, Throwable t) {
problem(msg);
// TODO(dborowitz): Expose stack trace to administrators.
log.warn("Error in consistency check of change " + change.getId(), t);
warn(t);
return false;
}
private void warn(Throwable t) {
log.warn("Error in consistency check of change " + change.getId(), t);
}
}

View File

@ -16,6 +16,7 @@ package com.google.gerrit.server.change;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import com.google.common.collect.SetMultimap;
import com.google.gerrit.common.ChangeHooks;
@ -113,6 +114,7 @@ public class PatchSetInserter {
private boolean runHooks;
private boolean sendMail;
private Account.Id uploader;
private boolean allowClosed;
@Inject
public PatchSetInserter(ChangeHooks hooks,
@ -173,7 +175,15 @@ public class PatchSetInserter {
return patchSet.getId();
}
public PatchSetInserter setMessage(String message) throws OrmException {
public PatchSet getPatchSet() {
checkState(patchSet != null,
"getPatchSet() only valid after patch set is created");
return patchSet;
}
public PatchSetInserter setMessage(String message)
throws OrmException, IOException {
init();
changeMessage = new ChangeMessage(
new ChangeMessage.Key(
ctl.getChange().getId(), ChangeUtil.messageUUID(db)),
@ -217,6 +227,11 @@ public class PatchSetInserter {
return this;
}
public PatchSetInserter setAllowClosed(boolean allowClosed) {
this.allowClosed = allowClosed;
return this;
}
public PatchSetInserter setUploader(Account.Id uploader) {
this.uploader = uploader;
return this;
@ -247,7 +262,7 @@ public class PatchSetInserter {
db.changes().beginTransaction(c.getId());
try {
updatedChange = db.changes().get(c.getId());
if (!updatedChange.getStatus().isOpen()) {
if (!updatedChange.getStatus().isOpen() && !allowClosed) {
throw new InvalidChangeOperationException(String.format(
"Change %s is closed", c.getId()));
}
@ -268,13 +283,13 @@ public class PatchSetInserter {
db.changes().atomicUpdate(c.getId(), new AtomicUpdate<Change>() {
@Override
public Change update(Change change) {
if (change.getStatus().isClosed()) {
if (change.getStatus().isClosed() && !allowClosed) {
return null;
}
if (!change.currentPatchSetId().equals(currentPatchSetId)) {
return null;
}
if (change.getStatus() != Change.Status.DRAFT) {
if (change.getStatus() != Change.Status.DRAFT && !allowClosed) {
change.setStatus(Change.Status.NEW);
}
change.setCurrentPatchSet(patchSetInfoFactory.get(commit,