| // Copyright (C) 2013 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.checkArgument; |
| import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER; |
| import static com.google.gerrit.server.notedb.ChangeNotes.parseException; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.CharMatcher; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ListMultimap; |
| import com.google.common.primitives.Ints; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.reviewdb.client.Change; |
| import com.google.gerrit.reviewdb.client.Comment; |
| import com.google.gerrit.reviewdb.client.CommentRange; |
| import com.google.gerrit.reviewdb.client.PatchSet; |
| import com.google.gerrit.reviewdb.client.RevId; |
| import com.google.gerrit.server.GerritPersonIdent; |
| import com.google.gerrit.server.account.AccountCache; |
| import com.google.gerrit.server.config.AnonymousCowardName; |
| import com.google.gerrit.server.config.GerritServerConfig; |
| import com.google.gerrit.server.config.GerritServerId; |
| import com.google.gson.Gson; |
| import com.google.gson.GsonBuilder; |
| import com.google.inject.Inject; |
| import java.io.OutputStream; |
| import java.io.OutputStreamWriter; |
| import java.io.PrintWriter; |
| import java.sql.Timestamp; |
| import java.text.ParseException; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Date; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.Set; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.eclipse.jgit.lib.Config; |
| import org.eclipse.jgit.lib.PersonIdent; |
| import org.eclipse.jgit.revwalk.FooterKey; |
| import org.eclipse.jgit.util.GitDateFormatter; |
| import org.eclipse.jgit.util.GitDateFormatter.Format; |
| import org.eclipse.jgit.util.GitDateParser; |
| import org.eclipse.jgit.util.MutableInteger; |
| import org.eclipse.jgit.util.QuotedString; |
| import org.eclipse.jgit.util.RawParseUtils; |
| |
| public class ChangeNoteUtil { |
| public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee"); |
| public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch"); |
| public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id"); |
| public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit"); |
| public static final FooterKey FOOTER_CURRENT = new FooterKey("Current"); |
| public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups"); |
| public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags"); |
| public static final FooterKey FOOTER_LABEL = new FooterKey("Label"); |
| public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set"); |
| public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = |
| new FooterKey("Patch-set-description"); |
| public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private"); |
| public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until"); |
| public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user"); |
| public static final FooterKey FOOTER_STATUS = new FooterKey("Status"); |
| public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject"); |
| public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id"); |
| public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with"); |
| public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic"); |
| public static final FooterKey FOOTER_TAG = new FooterKey("Tag"); |
| public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress"); |
| public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of"); |
| |
| private static final String AUTHOR = "Author"; |
| private static final String BASE_PATCH_SET = "Base-for-patch-set"; |
| private static final String COMMENT_RANGE = "Comment-range"; |
| private static final String FILE = "File"; |
| private static final String LENGTH = "Bytes"; |
| private static final String PARENT = "Parent"; |
| private static final String PARENT_NUMBER = "Parent-number"; |
| private static final String PATCH_SET = "Patch-set"; |
| private static final String REAL_AUTHOR = "Real-author"; |
| private static final String REVISION = "Revision"; |
| private static final String UUID = "UUID"; |
| private static final String UNRESOLVED = "Unresolved"; |
| private static final String TAG = FOOTER_TAG.getName(); |
| |
| public static String formatTime(PersonIdent ident, Timestamp t) { |
| GitDateFormatter dateFormatter = new GitDateFormatter(Format.DEFAULT); |
| // TODO(dborowitz): Use a ThreadLocal or use Joda. |
| PersonIdent newIdent = new PersonIdent(ident, t); |
| return dateFormatter.formatDate(newIdent); |
| } |
| |
| static Gson newGson() { |
| return new GsonBuilder() |
| .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe()) |
| .setPrettyPrinting() |
| .create(); |
| } |
| |
| private final AccountCache accountCache; |
| private final PersonIdent serverIdent; |
| private final String anonymousCowardName; |
| private final String serverId; |
| private final Gson gson = newGson(); |
| private final boolean writeJson; |
| |
| @Inject |
| public ChangeNoteUtil( |
| AccountCache accountCache, |
| @GerritPersonIdent PersonIdent serverIdent, |
| @AnonymousCowardName String anonymousCowardName, |
| @GerritServerId String serverId, |
| @GerritServerConfig Config config) { |
| this.accountCache = accountCache; |
| this.serverIdent = serverIdent; |
| this.anonymousCowardName = anonymousCowardName; |
| this.serverId = serverId; |
| this.writeJson = config.getBoolean("notedb", "writeJson", false); |
| } |
| |
| @VisibleForTesting |
| public PersonIdent newIdent( |
| Account author, Date when, PersonIdent serverIdent, String anonymousCowardName) { |
| return new PersonIdent( |
| author.getName(anonymousCowardName), |
| author.getId().get() + "@" + serverId, |
| when, |
| serverIdent.getTimeZone()); |
| } |
| |
| public boolean getWriteJson() { |
| return writeJson; |
| } |
| |
| public Gson getGson() { |
| return gson; |
| } |
| |
| public String getServerId() { |
| return serverId; |
| } |
| |
| public Account.Id parseIdent(PersonIdent ident, Change.Id changeId) |
| throws ConfigInvalidException { |
| String email = ident.getEmailAddress(); |
| int at = email.indexOf('@'); |
| if (at >= 0) { |
| String host = email.substring(at + 1, email.length()); |
| if (host.equals(serverId)) { |
| Integer id = Ints.tryParse(email.substring(0, at)); |
| if (id != null) { |
| return new Account.Id(id); |
| } |
| } |
| } |
| throw parseException(changeId, "invalid identity, expected <id>@%s: %s", serverId, email); |
| } |
| |
| private static boolean match(byte[] note, MutableInteger p, byte[] expected) { |
| int m = RawParseUtils.match(note, p.value, expected); |
| return m == p.value + expected.length; |
| } |
| |
| public List<Comment> parseNote(byte[] note, MutableInteger p, Change.Id changeId) |
| throws ConfigInvalidException { |
| if (p.value >= note.length) { |
| return ImmutableList.of(); |
| } |
| Set<Comment.Key> seen = new HashSet<>(); |
| List<Comment> result = new ArrayList<>(); |
| int sizeOfNote = note.length; |
| byte[] psb = PATCH_SET.getBytes(UTF_8); |
| byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8); |
| byte[] bpn = PARENT_NUMBER.getBytes(UTF_8); |
| |
| RevId revId = new RevId(parseStringField(note, p, changeId, REVISION)); |
| String fileName = null; |
| PatchSet.Id psId = null; |
| boolean isForBase = false; |
| Integer parentNumber = null; |
| |
| while (p.value < sizeOfNote) { |
| boolean matchPs = match(note, p, psb); |
| boolean matchBase = match(note, p, bpsb); |
| if (matchPs) { |
| fileName = null; |
| psId = parsePsId(note, p, changeId, PATCH_SET); |
| isForBase = false; |
| } else if (matchBase) { |
| fileName = null; |
| psId = parsePsId(note, p, changeId, BASE_PATCH_SET); |
| isForBase = true; |
| if (match(note, p, bpn)) { |
| parentNumber = parseParentNumber(note, p, changeId); |
| } |
| } else if (psId == null) { |
| throw parseException(changeId, "missing %s or %s header", PATCH_SET, BASE_PATCH_SET); |
| } |
| |
| Comment c = parseComment(note, p, fileName, psId, revId, isForBase, parentNumber); |
| fileName = c.key.filename; |
| if (!seen.add(c.key)) { |
| throw parseException(changeId, "multiple comments for %s in note", c.key); |
| } |
| result.add(c); |
| } |
| return result; |
| } |
| |
| private Comment parseComment( |
| byte[] note, |
| MutableInteger curr, |
| String currentFileName, |
| PatchSet.Id psId, |
| RevId revId, |
| boolean isForBase, |
| Integer parentNumber) |
| throws ConfigInvalidException { |
| Change.Id changeId = psId.getParentKey(); |
| |
| // Check if there is a new file. |
| boolean newFile = (RawParseUtils.match(note, curr.value, FILE.getBytes(UTF_8))) != -1; |
| if (newFile) { |
| // If so, parse the new file name. |
| currentFileName = parseFilename(note, curr, changeId); |
| } else if (currentFileName == null) { |
| throw parseException(changeId, "could not parse %s", FILE); |
| } |
| |
| CommentRange range = parseCommentRange(note, curr); |
| if (range == null) { |
| throw parseException(changeId, "could not parse %s", COMMENT_RANGE); |
| } |
| |
| Timestamp commentTime = parseTimestamp(note, curr, changeId); |
| Account.Id aId = parseAuthor(note, curr, changeId, AUTHOR); |
| boolean hasRealAuthor = |
| (RawParseUtils.match(note, curr.value, REAL_AUTHOR.getBytes(UTF_8))) != -1; |
| Account.Id raId = null; |
| if (hasRealAuthor) { |
| raId = parseAuthor(note, curr, changeId, REAL_AUTHOR); |
| } |
| |
| boolean hasParent = (RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1; |
| String parentUUID = null; |
| boolean unresolved = false; |
| if (hasParent) { |
| parentUUID = parseStringField(note, curr, changeId, PARENT); |
| } |
| boolean hasUnresolved = |
| (RawParseUtils.match(note, curr.value, UNRESOLVED.getBytes(UTF_8))) != -1; |
| if (hasUnresolved) { |
| unresolved = parseBooleanField(note, curr, changeId, UNRESOLVED); |
| } |
| |
| String uuid = parseStringField(note, curr, changeId, UUID); |
| |
| boolean hasTag = (RawParseUtils.match(note, curr.value, TAG.getBytes(UTF_8))) != -1; |
| String tag = null; |
| if (hasTag) { |
| tag = parseStringField(note, curr, changeId, TAG); |
| } |
| |
| int commentLength = parseCommentLength(note, curr, changeId); |
| |
| String message = RawParseUtils.decode(UTF_8, note, curr.value, curr.value + commentLength); |
| checkResult(message, "message contents", changeId); |
| |
| Comment c = |
| new Comment( |
| new Comment.Key(uuid, currentFileName, psId.get()), |
| aId, |
| commentTime, |
| isForBase ? (short) (parentNumber == null ? 0 : -parentNumber) : (short) 1, |
| message, |
| serverId, |
| unresolved); |
| c.lineNbr = range.getEndLine(); |
| c.parentUuid = parentUUID; |
| c.tag = tag; |
| c.setRevId(revId); |
| if (raId != null) { |
| c.setRealAuthor(raId); |
| } |
| |
| if (range.getStartCharacter() != -1) { |
| c.setRange(range); |
| } |
| |
| curr.value = RawParseUtils.nextLF(note, curr.value + commentLength); |
| curr.value = RawParseUtils.nextLF(note, curr.value); |
| return c; |
| } |
| |
| private static String parseStringField( |
| byte[] note, MutableInteger curr, Change.Id changeId, String fieldName) |
| throws ConfigInvalidException { |
| int endOfLine = RawParseUtils.nextLF(note, curr.value); |
| checkHeaderLineFormat(note, curr, fieldName, changeId); |
| int startOfField = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2; |
| curr.value = endOfLine; |
| return RawParseUtils.decode(UTF_8, note, startOfField, endOfLine - 1); |
| } |
| |
| /** |
| * @return a comment range. If the comment range line in the note only has one number, we return a |
| * CommentRange with that one number as the end line and the other fields as -1. If the |
| * comment range line in the note contains a whole comment range, then we return a |
| * CommentRange with all fields set. If the line is not correctly formatted, return null. |
| */ |
| private static CommentRange parseCommentRange(byte[] note, MutableInteger ptr) { |
| CommentRange range = new CommentRange(-1, -1, -1, -1); |
| |
| int last = ptr.value; |
| int startLine = RawParseUtils.parseBase10(note, ptr.value, ptr); |
| if (ptr.value == last) { |
| return null; |
| } else if (note[ptr.value] == '\n') { |
| range.setEndLine(startLine); |
| ptr.value += 1; |
| return range; |
| } else if (note[ptr.value] == ':') { |
| range.setStartLine(startLine); |
| ptr.value += 1; |
| } else { |
| return null; |
| } |
| |
| last = ptr.value; |
| int startChar = RawParseUtils.parseBase10(note, ptr.value, ptr); |
| if (ptr.value == last) { |
| return null; |
| } else if (note[ptr.value] == '-') { |
| range.setStartCharacter(startChar); |
| ptr.value += 1; |
| } else { |
| return null; |
| } |
| |
| last = ptr.value; |
| int endLine = RawParseUtils.parseBase10(note, ptr.value, ptr); |
| if (ptr.value == last) { |
| return null; |
| } else if (note[ptr.value] == ':') { |
| range.setEndLine(endLine); |
| ptr.value += 1; |
| } else { |
| return null; |
| } |
| |
| last = ptr.value; |
| int endChar = RawParseUtils.parseBase10(note, ptr.value, ptr); |
| if (ptr.value == last) { |
| return null; |
| } else if (note[ptr.value] == '\n') { |
| range.setEndCharacter(endChar); |
| ptr.value += 1; |
| } else { |
| return null; |
| } |
| return range; |
| } |
| |
| private static PatchSet.Id parsePsId( |
| byte[] note, MutableInteger curr, Change.Id changeId, String fieldName) |
| throws ConfigInvalidException { |
| checkHeaderLineFormat(note, curr, fieldName, changeId); |
| int startOfPsId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1; |
| MutableInteger i = new MutableInteger(); |
| int patchSetId = RawParseUtils.parseBase10(note, startOfPsId, i); |
| int endOfLine = RawParseUtils.nextLF(note, curr.value); |
| if (i.value != endOfLine - 1) { |
| throw parseException(changeId, "could not parse %s", fieldName); |
| } |
| checkResult(patchSetId, "patchset id", changeId); |
| curr.value = endOfLine; |
| return new PatchSet.Id(changeId, patchSetId); |
| } |
| |
| private static Integer parseParentNumber(byte[] note, MutableInteger curr, Change.Id changeId) |
| throws ConfigInvalidException { |
| checkHeaderLineFormat(note, curr, PARENT_NUMBER, changeId); |
| |
| int start = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1; |
| MutableInteger i = new MutableInteger(); |
| int parentNumber = RawParseUtils.parseBase10(note, start, i); |
| int endOfLine = RawParseUtils.nextLF(note, curr.value); |
| if (i.value != endOfLine - 1) { |
| throw parseException(changeId, "could not parse %s", PARENT_NUMBER); |
| } |
| checkResult(parentNumber, "parent number", changeId); |
| curr.value = endOfLine; |
| return Integer.valueOf(parentNumber); |
| } |
| |
| private static String parseFilename(byte[] note, MutableInteger curr, Change.Id changeId) |
| throws ConfigInvalidException { |
| checkHeaderLineFormat(note, curr, FILE, changeId); |
| int startOfFileName = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2; |
| int endOfLine = RawParseUtils.nextLF(note, curr.value); |
| curr.value = endOfLine; |
| curr.value = RawParseUtils.nextLF(note, curr.value); |
| return QuotedString.GIT_PATH.dequote( |
| RawParseUtils.decode(UTF_8, note, startOfFileName, endOfLine - 1)); |
| } |
| |
| private static Timestamp parseTimestamp(byte[] note, MutableInteger curr, Change.Id changeId) |
| throws ConfigInvalidException { |
| int endOfLine = RawParseUtils.nextLF(note, curr.value); |
| Timestamp commentTime; |
| String dateString = RawParseUtils.decode(UTF_8, note, curr.value, endOfLine - 1); |
| try { |
| commentTime = new Timestamp(GitDateParser.parse(dateString, null, Locale.US).getTime()); |
| } catch (ParseException e) { |
| throw new ConfigInvalidException("could not parse comment timestamp", e); |
| } |
| curr.value = endOfLine; |
| return checkResult(commentTime, "comment timestamp", changeId); |
| } |
| |
| private Account.Id parseAuthor( |
| byte[] note, MutableInteger curr, Change.Id changeId, String fieldName) |
| throws ConfigInvalidException { |
| checkHeaderLineFormat(note, curr, fieldName, changeId); |
| int startOfAccountId = RawParseUtils.endOfFooterLineKey(note, curr.value) + 2; |
| PersonIdent ident = RawParseUtils.parsePersonIdent(note, startOfAccountId); |
| Account.Id aId = parseIdent(ident, changeId); |
| curr.value = RawParseUtils.nextLF(note, curr.value); |
| return checkResult(aId, fieldName, changeId); |
| } |
| |
| private static int parseCommentLength(byte[] note, MutableInteger curr, Change.Id changeId) |
| throws ConfigInvalidException { |
| checkHeaderLineFormat(note, curr, LENGTH, changeId); |
| int startOfLength = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1; |
| MutableInteger i = new MutableInteger(); |
| i.value = startOfLength; |
| int commentLength = RawParseUtils.parseBase10(note, startOfLength, i); |
| if (i.value == startOfLength) { |
| throw parseException(changeId, "could not parse %s", LENGTH); |
| } |
| int endOfLine = RawParseUtils.nextLF(note, curr.value); |
| if (i.value != endOfLine - 1) { |
| throw parseException(changeId, "could not parse %s", LENGTH); |
| } |
| curr.value = endOfLine; |
| return checkResult(commentLength, "comment length", changeId); |
| } |
| |
| private boolean parseBooleanField( |
| byte[] note, MutableInteger curr, Change.Id changeId, String fieldName) |
| throws ConfigInvalidException { |
| String str = parseStringField(note, curr, changeId, fieldName); |
| if ("true".equalsIgnoreCase(str)) { |
| return true; |
| } else if ("false".equalsIgnoreCase(str)) { |
| return false; |
| } |
| throw parseException(changeId, "invalid boolean for %s: %s", fieldName, str); |
| } |
| |
| private static <T> T checkResult(T o, String fieldName, Change.Id changeId) |
| throws ConfigInvalidException { |
| if (o == null) { |
| throw parseException(changeId, "could not parse %s", fieldName); |
| } |
| return o; |
| } |
| |
| private static int checkResult(int i, String fieldName, Change.Id changeId) |
| throws ConfigInvalidException { |
| if (i <= 0) { |
| throw parseException(changeId, "could not parse %s", fieldName); |
| } |
| return i; |
| } |
| |
| private void appendHeaderField(PrintWriter writer, String field, String value) { |
| writer.print(field); |
| writer.print(": "); |
| writer.print(value); |
| writer.print('\n'); |
| } |
| |
| private static void checkHeaderLineFormat( |
| byte[] note, MutableInteger curr, String fieldName, Change.Id changeId) |
| throws ConfigInvalidException { |
| boolean correct = RawParseUtils.match(note, curr.value, fieldName.getBytes(UTF_8)) != -1; |
| int p = curr.value + fieldName.length(); |
| correct &= (p < note.length && note[p] == ':'); |
| p++; |
| correct &= (p < note.length && note[p] == ' '); |
| if (!correct) { |
| throw parseException(changeId, "could not parse %s", fieldName); |
| } |
| } |
| |
| /** |
| * Build a note that contains the metadata for and the contents of all of the comments in the |
| * given comments. |
| * |
| * @param comments Comments to be written to the output stream, keyed by patch set ID; multiple |
| * patch sets are allowed since base revisions may be shared across patch sets. All of the |
| * comments must share the same RevId, and all the comments for a given patch set must have |
| * the same side. |
| * @param out output stream to write to. |
| */ |
| void buildNote(ListMultimap<Integer, Comment> comments, OutputStream out) { |
| if (comments.isEmpty()) { |
| return; |
| } |
| |
| List<Integer> psIds = new ArrayList<>(comments.keySet()); |
| Collections.sort(psIds); |
| |
| OutputStreamWriter streamWriter = new OutputStreamWriter(out, UTF_8); |
| try (PrintWriter writer = new PrintWriter(streamWriter)) { |
| String revId = comments.values().iterator().next().revId; |
| appendHeaderField(writer, REVISION, revId); |
| |
| for (int psId : psIds) { |
| List<Comment> psComments = COMMENT_ORDER.sortedCopy(comments.get(psId)); |
| Comment first = psComments.get(0); |
| |
| short side = first.side; |
| appendHeaderField(writer, side <= 0 ? BASE_PATCH_SET : PATCH_SET, Integer.toString(psId)); |
| if (side < 0) { |
| appendHeaderField(writer, PARENT_NUMBER, Integer.toString(-side)); |
| } |
| |
| String currentFilename = null; |
| |
| for (Comment c : psComments) { |
| checkArgument( |
| revId.equals(c.revId), |
| "All comments being added must have all the same RevId. The " |
| + "comment below does not have the same RevId as the others " |
| + "(%s).\n%s", |
| revId, |
| c); |
| checkArgument( |
| side == c.side, |
| "All comments being added must all have the same side. The " |
| + "comment below does not have the same side as the others " |
| + "(%s).\n%s", |
| side, |
| c); |
| String commentFilename = QuotedString.GIT_PATH.quote(c.key.filename); |
| |
| if (!commentFilename.equals(currentFilename)) { |
| currentFilename = commentFilename; |
| writer.print("File: "); |
| writer.print(commentFilename); |
| writer.print("\n\n"); |
| } |
| |
| appendOneComment(writer, c); |
| } |
| } |
| } |
| } |
| |
| private void appendOneComment(PrintWriter writer, Comment c) { |
| // The CommentRange field for a comment is allowed to be null. If it is |
| // null, then in the first line, we simply use the line number field for a |
| // comment instead. If it isn't null, we write the comment range itself. |
| Comment.Range range = c.range; |
| if (range != null) { |
| writer.print(range.startLine); |
| writer.print(':'); |
| writer.print(range.startChar); |
| writer.print('-'); |
| writer.print(range.endLine); |
| writer.print(':'); |
| writer.print(range.endChar); |
| } else { |
| writer.print(c.lineNbr); |
| } |
| writer.print("\n"); |
| |
| writer.print(formatTime(serverIdent, c.writtenOn)); |
| writer.print("\n"); |
| |
| appendIdent(writer, AUTHOR, c.author.getId(), c.writtenOn); |
| if (!c.getRealAuthor().equals(c.author)) { |
| appendIdent(writer, REAL_AUTHOR, c.getRealAuthor().getId(), c.writtenOn); |
| } |
| |
| String parent = c.parentUuid; |
| if (parent != null) { |
| appendHeaderField(writer, PARENT, parent); |
| } |
| |
| appendHeaderField(writer, UNRESOLVED, Boolean.toString(c.unresolved)); |
| appendHeaderField(writer, UUID, c.key.uuid); |
| |
| if (c.tag != null) { |
| appendHeaderField(writer, TAG, c.tag); |
| } |
| |
| byte[] messageBytes = c.message.getBytes(UTF_8); |
| appendHeaderField(writer, LENGTH, Integer.toString(messageBytes.length)); |
| |
| writer.print(c.message); |
| writer.print("\n\n"); |
| } |
| |
| private void appendIdent(PrintWriter writer, String header, Account.Id id, Timestamp ts) { |
| PersonIdent ident = |
| newIdent(accountCache.get(id).getAccount(), ts, serverIdent, anonymousCowardName); |
| StringBuilder name = new StringBuilder(); |
| PersonIdent.appendSanitized(name, ident.getName()); |
| name.append(" <"); |
| PersonIdent.appendSanitized(name, ident.getEmailAddress()); |
| name.append('>'); |
| appendHeaderField(writer, header, name.toString()); |
| } |
| |
| private static final CharMatcher INVALID_FOOTER_CHARS = CharMatcher.anyOf("\r\n\0"); |
| |
| static String sanitizeFooter(String value) { |
| // Remove characters that would confuse JGit's footer parser if they were |
| // included in footer values, for example by splitting the footer block into |
| // multiple paragraphs. |
| // |
| // One painful example: RevCommit#getShorMessage() might return a message |
| // containing "\r\r", which RevCommit#getFooterLines() will treat as an |
| // empty paragraph for the purposes of footer parsing. |
| return INVALID_FOOTER_CHARS.trimAndCollapseFrom(value, ' '); |
| } |
| } |