blob: 0bad34df363e070efb7a4f149c3026bd6c2b1fe0 [file] [log] [blame]
// Copyright (C) 2021 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.notedb;
import static com.google.common.base.Preconditions.checkState;
import static com.google.gerrit.entities.ChangeMessage.ACCOUNT_TEMPLATE_REGEX;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.notedb.ChangeNoteUtil.CommitMessageRange;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jgit.diff.DiffAlgorithm;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.EditList;
import org.eclipse.jgit.diff.HistogramDiff;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.internal.storage.file.FileRepository;
import org.eclipse.jgit.internal.storage.file.PackInserter;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.FooterLine;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevSort;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.ReceiveCommand;
import org.eclipse.jgit.util.RawParseUtils;
/**
* Rewrites ('backfills') commit history of change in NoteDb to not contain user data. Only fixes
* known cases, rewriting commits case by case.
*
* <p>The cases where we used to put user data in NoteDb can be found by
* https://gerrit-review.googlesource.com/q/hashtag:user-data-cleanup
*
* <p>As opposed to {@link NoteDbRewriter} implementations, which target a specific change and are
* used by REST endpoints, this rewriter is used as standalone tool, that bulk backfills changes by
* project.
*/
@UsedAt(UsedAt.Project.GOOGLE)
@Singleton
public class CommitRewriter {
/** Options to run {@link #backfillProject}. */
public static class RunOptions {
/** Whether to rewrite the commit history or only find refs that need to be fixed. */
public boolean dryRun = true;
/**
* Whether to verify that resulting commits contain user data for the accounts that are linked
* to a change, see {@link #verifyCommit}, {@link #collectAccounts}.
*/
public boolean verifyCommits = true;
/** Whether to compute and output the diff of the commit history for the backfilled refs. */
public boolean outputDiff = true;
}
/** Result of the backfill run for a project. */
public static class BackfillResult {
/** If the run for the project was successful. */
public boolean ok;
/**
* Refs that were fixed by the run/ would be fixed if in --dry-run, together with their commit
* history diff. Diff is empty if --output-diff is false.
*/
public Map<String, List<String>> fixedRefDiff = new HashMap<>();
/**
* Refs that still contain user data after the backfill run. Only filled if --verify-commits,
* see {@link #verifyCommit}
*/
public List<String> refsStillInvalidAfterFix = new ArrayList<>();
/** Refs, failed to backfill by the run. */
public List<String> refsFailedToFix = new ArrayList<>();
}
private final ChangeNotes.Factory changeNotesFactory;
private final AccountCache accountCache;
private DiffAlgorithm diffAlgorithm = new HistogramDiff();
@Inject
public CommitRewriter(ChangeNotes.Factory changeNotesFactory, AccountCache accountCache) {
this.changeNotesFactory = changeNotesFactory;
this.accountCache = accountCache;
}
/**
* Rewrites commit history of {@link RefNames#changeMetaRef}s in single {@code repo}. Only
* rewrites branch if necessary, i.e. if there were any commits that contained user data.
*
* <p>See {@link RunOptions} for the execution and output options.
*
* @param project project to backfill
* @param repo repo to backfill
* @param options {@link RunOptions} to control how the run is executed.
* @return BackfillResult
*/
public BackfillResult backfillProject(
Project.NameKey project, Repository repo, RunOptions options) {
BackfillResult result = new BackfillResult();
result.ok = true;
try (RevWalk revWalk = new RevWalk(repo);
ObjectInserter ins = newPackInserter(repo)) {
BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
bru.setAllowNonFastForwards(true);
for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
Change.Id changeId = Change.Id.fromRef(ref.getName());
if (changeId == null || !ref.getName().equals(RefNames.changeMetaRef(changeId))) {
continue;
}
ChangeNotes changeNotes = changeNotesFactory.create(project, changeId);
ImmutableSet<AccountState> accountsInChange =
options.verifyCommits ? collectAccounts(changeNotes) : ImmutableSet.of();
try {
ChangeFixProgress changeFixProgress =
backfillChange(revWalk, ins, ref, accountsInChange, options);
if (changeFixProgress.anyFixesApplied) {
bru.addCommand(
new ReceiveCommand(ref.getObjectId(), changeFixProgress.newTipId, ref.getName()));
result.fixedRefDiff.put(ref.getName(), changeFixProgress.commitDiffs);
}
if (!changeFixProgress.isValidAfterFix) {
result.refsStillInvalidAfterFix.add(ref.getName());
}
} catch (ConfigInvalidException | IOException e) {
result.refsFailedToFix.add(ref.getName());
}
}
if (!bru.getCommands().isEmpty()) {
if (!options.dryRun) {
ins.flush();
RefUpdateUtil.executeChecked(bru, revWalk);
}
}
} catch (IOException e) {
result.ok = false;
}
return result;
}
/**
* Retrieves accounts, that are associated with a change (e.g. reviewers, commenters, etc.). These
* accounts are used to verify that commits do not contain user data. See {@link #verifyCommit}
*
* @param changeNotes {@link ChangeNotes} of the change to retrieve associated accounts from.
* @return {@link AccountState} of accounts, that are associated with the change.
*/
private ImmutableSet<AccountState> collectAccounts(ChangeNotes changeNotes) {
Set<Account.Id> accounts = new HashSet<>();
accounts.add(changeNotes.getChange().getOwner());
for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().values()) {
accounts.add(patchSetApproval.accountId());
accounts.add(patchSetApproval.realAccountId());
}
accounts.addAll(changeNotes.getAllPastReviewers());
accounts.addAll(changeNotes.getPastAssignees());
changeNotes
.getAttentionSetUpdates()
.forEach(attentionSetUpdate -> accounts.add(attentionSetUpdate.account()));
for (SubmitRecord submitRecord : changeNotes.getSubmitRecords()) {
accounts.addAll(
submitRecord.labels.stream()
.map(label -> label.appliedBy)
.filter(Objects::nonNull)
.collect(Collectors.toSet()));
}
for (HumanComment comment : changeNotes.getHumanComments().values()) {
accounts.add(comment.author.getId());
accounts.add(comment.getRealAuthor().getId());
}
return ImmutableSet.copyOf(accountCache.get(accounts).values());
}
/** Verifies that the commit does not contain user data of accounts in {@code accounts}. */
private boolean verifyCommit(
String commitMessage, PersonIdent author, Collection<AccountState> accounts) {
for (AccountState accountState : accounts) {
Account account = accountState.account();
if (commitMessage.contains(account.getName())) {
return false;
}
if (account.fullName() != null && commitMessage.contains(account.fullName())) {
return false;
}
if (account.displayName() != null && commitMessage.contains(account.displayName())) {
return false;
}
if (account.preferredEmail() != null && commitMessage.contains(account.preferredEmail())) {
return false;
}
if (accountState.userName().isPresent()
&& commitMessage.contains(accountState.userName().get())) {
return false;
}
Stream<String> allEmails =
accountState.externalIds().stream().map(ExternalId::email).filter(Objects::nonNull);
if (allEmails.anyMatch(email -> commitMessage.contains(email))) {
return false;
}
if (author.toString().contains(account.getName())) {
return false;
}
}
return true;
}
/**
* Walks the ref history from oldest update to the most recent update, fixing the commits that
* contain user data case by case. Commit history is rewritten from the first commit, that needs
* to be updated, for all subsequent updates. The new ref tip is returned in {@link
* ChangeFixProgress#newTipId}.
*/
public ChangeFixProgress backfillChange(
RevWalk revWalk,
ObjectInserter inserter,
Ref ref,
ImmutableSet<AccountState> accountsInChange,
RunOptions options)
throws IOException, ConfigInvalidException {
ObjectId oldTip = ref.getObjectId();
// Walk from the first commit of the branch.
revWalk.reset();
revWalk.markStart(revWalk.parseCommit(oldTip));
revWalk.sort(RevSort.TOPO);
revWalk.sort(RevSort.REVERSE);
RevCommit originalCommit;
boolean rewriteStarted = false;
ChangeFixProgress changeFixProgress = new ChangeFixProgress();
while ((originalCommit = revWalk.next()) != null) {
changeFixProgress.updateAuthorId = parseIdent(originalCommit.getAuthorIdent());
PersonIdent fixedAuthorIdent =
getFixedIdent(originalCommit.getAuthorIdent(), changeFixProgress.updateAuthorId);
Optional<String> fixedCommitMessage = fixedCommitMessage(originalCommit, changeFixProgress);
String commitMessage =
fixedCommitMessage.isPresent()
? fixedCommitMessage.get()
: originalCommit.getFullMessage();
if (options.verifyCommits) {
changeFixProgress.isValidAfterFix &=
verifyCommit(commitMessage, fixedAuthorIdent, accountsInChange);
}
boolean needsFix =
!fixedAuthorIdent.equals(originalCommit.getAuthorIdent())
|| fixedCommitMessage.isPresent();
if (!rewriteStarted && !needsFix) {
changeFixProgress.newTipId = originalCommit;
continue;
}
rewriteStarted = true;
changeFixProgress.anyFixesApplied = true;
CommitBuilder cb = new CommitBuilder();
if (changeFixProgress.newTipId != null) {
cb.setParentId(changeFixProgress.newTipId);
}
cb.setTreeId(originalCommit.getTree());
cb.setMessage(commitMessage);
cb.setAuthor(fixedAuthorIdent);
cb.setCommitter(originalCommit.getCommitterIdent());
cb.setEncoding(originalCommit.getEncoding());
byte[] newCommitContent = cb.build();
checkCommitModification(originalCommit, newCommitContent);
changeFixProgress.newTipId = inserter.insert(Constants.OBJ_COMMIT, newCommitContent);
// Only compute diff if the content of the commit was actually changed.
if (options.outputDiff && needsFix) {
String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
checkState(
!Strings.isNullOrEmpty(diff),
"Expected diff for commit %s of ref %s",
originalCommit.getId(),
ref.getName());
changeFixProgress.commitDiffs.add(diff);
}
}
return changeFixProgress;
}
/**
* In NoteDb, all the meta information is stored in footer lines. If we accidentally drop some of
* the footer lines, the original meta information will be lost, and the change might become
* unparsable.
*
* <p>While we can not verify the entire commit content, we at least make sure that the resulting
* commit has the same author, committer and footer lines are in the same order and contain same
* footer keys as the original commit.
*
* <p>Commit message and footer values might have been rewritten.
*/
private void checkCommitModification(RevCommit originalCommit, byte[] newCommitContent)
throws IOException {
RevCommit newCommit = RevCommit.parse(newCommitContent);
PersonIdent newAuthorIdent = newCommit.getAuthorIdent();
PersonIdent originalAuthorIdent = originalCommit.getAuthorIdent();
// The new commit must have same author and committer ident as the original commit.
if (!verifyPersonIdent(newAuthorIdent, originalAuthorIdent)) {
throw new IllegalStateException(
String.format(
"New author %s does not match original author %s",
newAuthorIdent.toExternalString(), originalAuthorIdent.toExternalString()));
}
PersonIdent newCommitterIdent = newCommit.getCommitterIdent();
PersonIdent originalCommitterIdent = originalCommit.getCommitterIdent();
if (!verifyPersonIdent(newCommitterIdent, originalCommitterIdent)) {
throw new IllegalStateException(
String.format(
"New committer %s does not match original committer %s",
newCommitterIdent.toExternalString(), originalCommitterIdent.toExternalString()));
}
List<FooterLine> newFooterLines = newCommit.getFooterLines();
List<FooterLine> originalFooterLines = originalCommit.getFooterLines();
// Number and order of footer lines must remain the same, the value may have changed.
if (newFooterLines.size() != originalFooterLines.size()) {
String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
throw new IllegalStateException(
String.format(
"Expected footer lines in new commit to match original footer lines. Diff %s", diff));
}
for (int i = 0; i < newFooterLines.size(); i++) {
FooterLine newFooterLine = newFooterLines.get(i);
FooterLine originalFooterLine = originalFooterLines.get(i);
if (!newFooterLine.getKey().equals(originalFooterLine.getKey())) {
String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
throw new IllegalStateException(
String.format(
"Expected footer lines in new commit to match original footer lines. Diff %s",
diff));
}
}
}
private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
&& newIdent.getWhen().equals(originalIdent.getWhen())
&& newIdent.getEmailAddress().equals(originalIdent.getEmailAddress());
}
private Optional<String> fixAssigneeChangeMessage(
Account.Id oldAssignee, Account.Id newAssignee, String originalChangeMessage) {
if (Strings.isNullOrEmpty(originalChangeMessage)) {
return Optional.empty();
}
Pattern assigneeDeletedPattern = Pattern.compile("Assignee deleted: (.*)");
Matcher assigneeDeletedMatcher = assigneeDeletedPattern.matcher(originalChangeMessage);
if (assigneeDeletedMatcher.matches()) {
if (!assigneeDeletedMatcher.group(1).matches(ACCOUNT_TEMPLATE_REGEX)) {
return Optional.of(
"Assignee deleted: " + ChangeMessagesUtil.getAccountTemplate(oldAssignee));
}
return Optional.empty();
}
Pattern assigneeAddedPattern = Pattern.compile("Assignee added: (.*)");
Matcher assigneeAddedMatcher = assigneeAddedPattern.matcher(originalChangeMessage);
if (assigneeAddedMatcher.matches()) {
if (!assigneeAddedMatcher.group(1).matches(ACCOUNT_TEMPLATE_REGEX)) {
return Optional.of("Assignee added: " + ChangeMessagesUtil.getAccountTemplate(newAssignee));
}
return Optional.empty();
}
Pattern assigneeChangedPattern = Pattern.compile("Assignee changed from: (.*) to: (.*)");
Matcher assigneeChangedMatcher = assigneeChangedPattern.matcher(originalChangeMessage);
if (assigneeChangedMatcher.matches()) {
if (!assigneeChangedMatcher.group(1).matches(ACCOUNT_TEMPLATE_REGEX)) {
return Optional.of(
String.format(
"Assignee changed from: %s to: %s",
ChangeMessagesUtil.getAccountTemplate(oldAssignee),
ChangeMessagesUtil.getAccountTemplate(newAssignee)));
}
return Optional.empty();
}
return Optional.empty();
}
private Optional<String> fixReviewerChangeMessage(String originalChangeMessage) {
if (Strings.isNullOrEmpty(originalChangeMessage)) {
return Optional.empty();
}
Pattern removedReviewer = Pattern.compile("Removed (cc|reviewer) (.*) .*");
Matcher matcher = removedReviewer.matcher(originalChangeMessage);
if (matcher.matches() && !matcher.group(2).matches(ACCOUNT_TEMPLATE_REGEX)) {
// Since we do not use change messages for reviewer updates on UI, it does not matter what we
// rewrite it to.
return Optional.of(originalChangeMessage.substring(0, matcher.end(1)));
}
return Optional.empty();
}
private Optional<String> fixRemoveVoteChangeMessage(
Account.Id reviewer, String originalChangeMessage) {
if (Strings.isNullOrEmpty(originalChangeMessage)) {
return Optional.empty();
}
Pattern removedVotePattern = Pattern.compile("Removed (.*) by (.*)");
Matcher matcher = removedVotePattern.matcher(originalChangeMessage);
if (matcher.matches() && !matcher.group(2).matches(ACCOUNT_TEMPLATE_REGEX)) {
return Optional.of(
String.format(
"Removed %s by %s",
matcher.group(1), ChangeMessagesUtil.getAccountTemplate(reviewer)));
}
return Optional.empty();
}
private Optional<String> fixDeleteChangeMessageCommitMessage(String originalChangeMessage) {
if (Strings.isNullOrEmpty(originalChangeMessage)) {
return Optional.empty();
}
Pattern removedChangeMessage =
Pattern.compile("Change message removed by: (.*)(\nReason: .*)?");
Matcher matcher = removedChangeMessage.matcher(originalChangeMessage);
if (matcher.matches() && !matcher.group(1).matches(ACCOUNT_TEMPLATE_REGEX)) {
String fixedMessage = "Change message removed";
if (matcher.group(2) != null) {
fixedMessage += matcher.group(2);
}
return Optional.of(fixedMessage);
}
return Optional.empty();
}
private Optional<String> fixSubmitChangeMessage(String originalChangeMessage) {
if (Strings.isNullOrEmpty(originalChangeMessage)) {
return Optional.empty();
}
Pattern submittedPattern = Pattern.compile("Change has been successfully (.*) by (.*)");
Matcher matcher = submittedPattern.matcher(originalChangeMessage);
if (matcher.matches()) {
// See https://gerrit-review.googlesource.com/c/gerrit/+/272654
return Optional.of(originalChangeMessage.substring(0, matcher.end(1)));
}
return Optional.empty();
}
/**
* Rewrites a code owners change message.
*
* @param originalMessage the original change message
* @return the updated change message
*/
private Optional<String> fixCodeOwnersChangeMessage(String originalMessage) {
// TODO(mariasavtchouk): backfill this case
return Optional.empty();
}
/**
* Fixes commit body case by case, so it does not contain user data. Returns fixed commit message,
* or {@link Optional#empty} if no fixes were applied.
*/
private Optional<String> fixedCommitMessage(RevCommit revCommit, ChangeFixProgress fixProgress)
throws ConfigInvalidException {
byte[] raw = revCommit.getRawBuffer();
Charset enc = RawParseUtils.parseEncoding(raw);
Optional<CommitMessageRange> commitMessageRange =
ChangeNoteUtil.parseCommitMessageRange(revCommit);
if (!commitMessageRange.isPresent()) {
throw new ConfigInvalidException("Failed to parse commit message " + revCommit.getName());
}
String changeSubject =
RawParseUtils.decode(
enc,
raw,
commitMessageRange.get().subjectStart(),
commitMessageRange.get().subjectEnd());
Optional<String> fixedChangeMessage = Optional.empty();
String originalChangeMessage = null;
if (commitMessageRange.isPresent() && commitMessageRange.get().hasChangeMessage()) {
originalChangeMessage =
RawParseUtils.decode(
enc,
raw,
commitMessageRange.get().changeMessageStart(),
commitMessageRange.get().changeMessageEnd() + 1)
.trim();
}
List<FooterLine> footerLines = revCommit.getFooterLines();
StringBuilder footerLinesBuilder = new StringBuilder();
boolean anyFootersFixed = false;
for (FooterLine fl : footerLines) {
String footerKey = fl.getKey();
String footerValue = fl.getValue();
if (footerKey.equals(FOOTER_TAG.getName())) {
if (footerValue.equals(ChangeMessagesUtil.TAG_MERGED)) {
fixedChangeMessage = fixSubmitChangeMessage(originalChangeMessage);
}
} else if (footerKey.equalsIgnoreCase(FOOTER_ASSIGNEE.getName())) {
Account.Id oldAssignee = fixProgress.assigneeId;
FixIdentResult fixedAssignee = null;
if (footerValue.equals("")) {
fixProgress.assigneeId = null;
} else {
fixedAssignee = getFixedIdentString(footerValue);
fixProgress.assigneeId = fixedAssignee.accountId;
}
fixedChangeMessage =
fixAssigneeChangeMessage(oldAssignee, fixProgress.assigneeId, originalChangeMessage);
if (fixedAssignee != null && fixedAssignee.fixedIdentString.isPresent()) {
addFooter(footerLinesBuilder, footerKey, fixedAssignee.fixedIdentString.get());
anyFootersFixed = true;
continue;
}
} else if (Arrays.stream(ReviewerStateInternal.values())
.filter(state -> footerKey.equalsIgnoreCase(state.getFooterKey().getName()))
.findAny()
.isPresent()) {
fixedChangeMessage = fixReviewerChangeMessage(originalChangeMessage);
FixIdentResult fixedReviewer = getFixedIdentString(footerValue);
if (fixedReviewer.fixedIdentString.isPresent()) {
addFooter(footerLinesBuilder, footerKey, fixedReviewer.fixedIdentString.get());
anyFootersFixed = true;
continue;
}
} else if (footerKey.equalsIgnoreCase(FOOTER_REAL_USER.getName())) {
FixIdentResult fixedRealUser = getFixedIdentString(footerValue);
if (fixedRealUser.fixedIdentString.isPresent()) {
addFooter(footerLinesBuilder, footerKey, fixedRealUser.fixedIdentString.get());
anyFootersFixed = true;
continue;
}
} else if (footerKey.equalsIgnoreCase(FOOTER_LABEL.getName())) {
int voterIdentStart = footerValue.indexOf(' ');
FixIdentResult fixedVoter = null;
if (voterIdentStart > 0) {
String originalIdentString = footerValue.substring(voterIdentStart + 1);
fixedVoter = getFixedIdentString(originalIdentString);
}
fixedChangeMessage =
fixRemoveVoteChangeMessage(
fixedVoter == null ? fixProgress.updateAuthorId : fixedVoter.accountId,
originalChangeMessage);
if (fixedVoter != null && fixedVoter.fixedIdentString.isPresent()) {
String fixedLabelVote =
footerValue.substring(0, voterIdentStart) + " " + fixedVoter.fixedIdentString.get();
addFooter(footerLinesBuilder, footerKey, fixedLabelVote);
anyFootersFixed = true;
continue;
}
} else if (footerKey.equalsIgnoreCase(FOOTER_SUBMITTED_WITH.getName())) {
// TODO(mariasavtchouk): backfill this case
} else if (footerKey.equalsIgnoreCase(FOOTER_ATTENTION.getName())) {
// TODO(mariasavtchouk): backfill this case
}
addFooter(footerLinesBuilder, footerKey, footerValue);
}
if (!fixedChangeMessage.isPresent()) {
fixedChangeMessage = fixDeleteChangeMessageCommitMessage(originalChangeMessage);
}
if (!fixedChangeMessage.isPresent()) {
fixedChangeMessage = fixCodeOwnersChangeMessage(originalChangeMessage);
}
if (!anyFootersFixed && !fixedChangeMessage.isPresent()) {
return Optional.empty();
}
StringBuilder fixedCommitBuilder = new StringBuilder();
fixedCommitBuilder.append(changeSubject);
fixedCommitBuilder.append("\n\n");
if (commitMessageRange.get().hasChangeMessage()) {
fixedCommitBuilder.append(
fixedChangeMessage.isPresent() ? fixedChangeMessage.get() : originalChangeMessage);
fixedCommitBuilder.append("\n\n");
}
fixedCommitBuilder.append(footerLinesBuilder);
return Optional.of(fixedCommitBuilder.toString());
}
private static StringBuilder addFooter(StringBuilder sb, String footer, String value) {
sb.append(footer).append(":");
if (!Strings.isNullOrEmpty(value)) {
sb.append(" ").append(value);
}
sb.append('\n');
return sb;
}
private Account.Id parseIdent(PersonIdent ident) throws ConfigInvalidException {
return NoteDbUtil.parseIdent(ident)
.orElseThrow(
() -> new ConfigInvalidException("field to parse id: " + ident.getEmailAddress()));
}
/**
* Fixes {@code originalIdent} so it does not contain user data, see {@link
* ChangeNoteUtil#getAccountIdAsUsername}.
*/
private PersonIdent getFixedIdent(PersonIdent originalIdent, Account.Id identAccount) {
return new PersonIdent(
ChangeNoteUtil.getAccountIdAsUsername(identAccount),
originalIdent.getEmailAddress(),
originalIdent.getWhen(),
originalIdent.getTimeZone());
}
/**
* Parses {@code originalIdentString} and applies the fix, so it does not contain user data, see
* {@link ChangeNoteUtil#appendAccountIdIdentString}.
*
* @param originalIdentString ident to apply the fix to.
* @return {@link FixIdentResult}, with {@link FixIdentResult#accountId} parsed from {@code
* originalIdentString} and {@link FixIdentResult#fixedIdentString} if the fix was applied.
* @throws ConfigInvalidException if could not parse {@link FixIdentResult#accountId} from {@code
* originalIdentString}
*/
private FixIdentResult getFixedIdentString(String originalIdentString)
throws ConfigInvalidException {
FixIdentResult fixIdentResult = new FixIdentResult();
PersonIdent originalIdent = RawParseUtils.parsePersonIdent(originalIdentString);
fixIdentResult.accountId = parseIdent(originalIdent);
String fixedIdentString =
ChangeNoteUtil.formatAccountIdentString(
fixIdentResult.accountId, originalIdent.getEmailAddress());
fixIdentResult.fixedIdentString =
fixedIdentString.equals(originalIdentString)
? Optional.empty()
: Optional.of(fixedIdentString);
return fixIdentResult;
}
/**
* Cuts tree and parent lines from raw unparsed commit body, so they are not included in diff
* comparison.
*
* @param b raw unparsed commit body, see {@link RevCommit#getRawBuffer()}.
* <p>For parsing, see {@link RawParseUtils#author}, {@link RawParseUtils#commitMessage}, etc.
* @return raw unparsed commit body, without tree and parent lines.
*/
public static byte[] cutTreeAndParents(byte[] b) {
final int sz = b.length;
int ptr = 46; // skip the "tree ..." line.
while (ptr < sz && b[ptr] == 'p') ptr += 48; // skip this parent.
return Arrays.copyOfRange(b, ptr, b.length + 1);
}
private String computeDiff(byte[] oldCommit, byte[] newCommit) throws IOException {
RawText oldBody = new RawText(cutTreeAndParents(oldCommit));
RawText newBody = new RawText(cutTreeAndParents(newCommit));
ByteArrayOutputStream out = new ByteArrayOutputStream();
EditList diff = diffAlgorithm.diff(RawTextComparator.DEFAULT, oldBody, newBody);
try (DiffFormatter fmt = new DiffFormatter(out)) {
// Do not show any unchanged lines, since it is not interesting
fmt.setContext(0);
fmt.format(diff, oldBody, newBody);
fmt.flush();
return out.toString();
}
}
private static ObjectInserter newPackInserter(Repository repo) {
if (!(repo instanceof FileRepository)) {
return repo.newObjectInserter();
}
PackInserter ins = ((FileRepository) repo).getObjectDatabase().newPackInserter();
ins.checkExisting(false);
return ins;
}
/**
* Parsed and fixed {@link PersonIdent} string, formatted as {@link
* ChangeNoteUtil#appendAccountIdIdentString}
*/
private static class FixIdentResult {
/** {@link com.google.gerrit.entities.Account.Id} parsed from PersonIdent string. */
Account.Id accountId;
/**
* Fixed ident string, that does not contain user data, or {@link Optional#empty} if fix was not
* required.
*/
Optional<String> fixedIdentString;
}
/**
* Holds the state of change rewrite progress. Rewrite goes from the oldest commit to the most
* recent update.
*/
private static class ChangeFixProgress {
/** Assignee at current commit update. */
Account.Id assigneeId = null;
/** Author of the current commit update. */
Account.Id updateAuthorId = null;
/** Id of the current commit in rewriter walk. */
ObjectId newTipId = null;
/** If any commits were rewritten by the rewriter. */
boolean anyFixesApplied = false;
/**
* Whether all commits seen by the rewriter with the fixes applied passed the verification, see
* {@link #verifyCommit}.
*/
boolean isValidAfterFix = true;
List<String> commitDiffs = new ArrayList<>();
}
}