Implement PatchUtil
This is intended to replace patch command line tool that's used in
repository rules.
The native patch implementation depends on
https://code.google.com/archive/p/java-diff-utils. We implemented some
extened features to support matching chunk with an offset and support
git format patching.
Working towards: https://github.com/bazelbuild/bazel/issues/8316
Closes #8773.
PiperOrigin-RevId: 256921218
diff --git a/src/main/java/com/google/devtools/build/lib/BUILD b/src/main/java/com/google/devtools/build/lib/BUILD
index e724b46..a777b7b 100644
--- a/src/main/java/com/google/devtools/build/lib/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/BUILD
@@ -984,6 +984,7 @@
"//third_party:aether",
"//third_party:apache_commons_compress",
"//third_party:auto_value",
+ "//third_party:diffutils",
"//third_party:guava",
"//third_party:jsr305",
"//third_party:maven",
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/PatchUtil.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/PatchUtil.java
new file mode 100644
index 0000000..e9ffee0
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/PatchUtil.java
@@ -0,0 +1,581 @@
+// Copyright 2019 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.build.lib.bazel.repository;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import difflib.Chunk;
+import difflib.Delta;
+import difflib.DeltaComparator;
+import difflib.DiffUtils;
+import difflib.Patch;
+import difflib.PatchFailedException;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Implementation of native patch. */
+public class PatchUtil {
+
+ private static final Pattern CHUNK_HEADER_RE =
+ Pattern.compile("^@@\\s+-(?:(\\d+)(?:,(\\d+))?)\\s+\\+(?:(\\d+)(?:,(\\d+))?)\\s+@@$");
+
+ /** The possible results of ChunkHeader.check. */
+ public enum Result {
+ COMPLETE, // The entire chunk is read
+ CONTINUE, // Should continue reading the chunk
+ ERROR, // The chunk body doesn't match the chunk header's size description
+ }
+
+ private static class ChunkHeader {
+ private final int oldSize;
+ private final int newSize;
+
+ public Result check(int oldLineCnt, int newLineCnt) {
+ if (oldLineCnt == oldSize && newLineCnt == newSize) {
+ return Result.COMPLETE;
+ }
+ if (oldLineCnt <= oldSize && newLineCnt <= newSize) {
+ return Result.CONTINUE;
+ }
+ return Result.ERROR;
+ }
+
+ ChunkHeader(String header) throws PatchFailedException {
+ Matcher m = CHUNK_HEADER_RE.matcher(header);
+ if (m.find()) {
+ oldSize = Integer.parseInt(m.group(2));
+ newSize = Integer.parseInt(m.group(4));
+ } else {
+ throw new PatchFailedException("Wrong chunk header: " + header);
+ }
+ }
+ }
+
+ /**
+ * Sometimes the line number in patch file is not completely correct, but we might still be able
+ * to find a content match with an offset.
+ */
+ private static class OffsetPatch {
+
+ public static List<String> applyTo(Patch<String> patch, List<String> target)
+ throws PatchFailedException {
+ List<Delta<String>> deltas = patch.getDeltas();
+ List<String> result = new ArrayList<>(target);
+ deltas.sort(DeltaComparator.INSTANCE);
+ for (Delta<String> item : Lists.reverse(deltas)) {
+ Delta<String> delta = item;
+ applyTo(delta, result);
+ }
+
+ return result;
+ }
+
+ /**
+ * This function first tries to apply the Delta without any offset, if that fails, then it tries
+ * to apply the Delta with an offset, starting from 1, up to the total lines in the original
+ * content. For every offset, we try both forwards and backwards.
+ */
+ private static void applyTo(Delta<String> delta, List<String> result)
+ throws PatchFailedException {
+ PatchFailedException e = applyDelta(delta, result);
+ if (e == null) {
+ return;
+ }
+
+ Chunk<String> original = delta.getOriginal();
+ Chunk<String> revised = delta.getRevised();
+ int[] direction = {1, -1};
+ int maxOffset = result.size();
+ for (int i = 1; i < maxOffset; i++) {
+ for (int j = 0; j < 2; j++) {
+ int offset = i * direction[j];
+ if (offset + original.getPosition() < 0 || offset + revised.getPosition() < 0) {
+ continue;
+ }
+ delta.setOriginal(new Chunk<>(original.getPosition() + offset, original.getLines()));
+ delta.setRevised(new Chunk<>(revised.getPosition() + offset, revised.getLines()));
+ PatchFailedException exception = applyDelta(delta, result);
+ if (exception == null) {
+ return;
+ }
+ }
+ }
+
+ throw e;
+ }
+
+ private static PatchFailedException applyDelta(Delta<String> delta, List<String> result) {
+ try {
+ delta.applyTo(result);
+ return null;
+ } catch (PatchFailedException e) {
+ return new PatchFailedException(e.getMessage() + "\nFailed to apply delta:\n " + delta);
+ }
+ }
+ }
+
+ private enum LineType {
+ OLD_FILE,
+ NEW_FILE,
+ CHUNK_HEAD,
+ CHUNK_ADD,
+ CHUNK_DEL,
+ CHUNK_EQL,
+ GIT_HEADER,
+ RENAME_FROM,
+ RENAME_TO,
+ GIT_LINE,
+ UNKNOWN
+ }
+
+ private static final String[] GIT_LINE_PREFIXES = {
+ "old mode ",
+ "new mode ",
+ "deleted file mode ",
+ "new file mode ",
+ "copy from ",
+ "copy to ",
+ "rename old ",
+ "rename new ",
+ "similarity index ",
+ "dissimilarity index ",
+ "index "
+ };
+
+ private static LineType getLineType(String line, boolean isReadingChunk, boolean isGitDiff) {
+ if (isReadingChunk) {
+ if (line.startsWith("+")) {
+ return LineType.CHUNK_ADD;
+ }
+ if (line.startsWith("-")) {
+ return LineType.CHUNK_DEL;
+ }
+ if (line.startsWith(" ") || line.isEmpty()) {
+ return LineType.CHUNK_EQL;
+ }
+ } else {
+ if (line.startsWith("--- ")) {
+ return LineType.OLD_FILE;
+ }
+ if (line.startsWith("+++ ")) {
+ return LineType.NEW_FILE;
+ }
+ if (line.startsWith("diff --git ")) {
+ return LineType.GIT_HEADER;
+ }
+ if (isGitDiff) {
+ // Only recognize the following when we saw "diff --git " before.
+ if (line.startsWith("rename from ")) {
+ return LineType.RENAME_FROM;
+ }
+ if (line.startsWith("rename to ")) {
+ return LineType.RENAME_TO;
+ }
+ for (String prefix : GIT_LINE_PREFIXES) {
+ if (line.startsWith(prefix)) {
+ return LineType.GIT_LINE;
+ }
+ }
+ }
+ }
+ if (line.startsWith("@@") && line.lastIndexOf("@@") != 0) {
+ int pos = line.indexOf("@@", 2);
+ Matcher m = CHUNK_HEADER_RE.matcher(line.substring(0, pos + 2));
+ if (m.find()) {
+ return LineType.CHUNK_HEAD;
+ }
+ }
+ return LineType.UNKNOWN;
+ }
+
+ @VisibleForTesting
+ public static List<String> readFile(Path file) throws IOException {
+ return Lists.newArrayList(FileSystemUtils.readLines(file, StandardCharsets.UTF_8));
+ }
+
+ private static void writeFile(Path file, List<String> content) throws IOException {
+ FileSystemUtils.writeLinesAs(file, StandardCharsets.UTF_8, content);
+ }
+
+ private static void applyPatchToFile(
+ Patch<String> patch, Path oldFile, Path newFile, boolean isRenaming)
+ throws IOException, PatchFailedException {
+ // The file we should read oldContent from.
+ Path inputFile = null;
+ if (oldFile != null && oldFile.exists()) {
+ inputFile = oldFile;
+ } else if (newFile != null && newFile.exists()) {
+ inputFile = newFile;
+ }
+
+ List<String> oldContent;
+ if (inputFile == null) {
+ oldContent = new ArrayList<>();
+ } else {
+ oldContent = readFile(inputFile);
+ }
+
+ List<String> newContent = OffsetPatch.applyTo(patch, oldContent);
+
+ // The file we should write newContent to.
+ Path outputFile;
+ if (oldFile != null && oldFile.exists() && !isRenaming) {
+ outputFile = oldFile;
+ } else {
+ outputFile = newFile;
+ }
+
+ // The old file should always change, therefore we can just delete the original file.
+ // If the output file name is the same as the old file, we'll just recreate it later.
+ if (oldFile != null) {
+ oldFile.delete();
+ }
+
+ // Does this patch look like deleting a file.
+ boolean isDeleteFile = newFile == null && newContent.isEmpty();
+
+ if (outputFile != null && !isDeleteFile) {
+ writeFile(outputFile, newContent);
+ }
+ }
+
+ /**
+ * Strip a number of leading components from a path
+ *
+ * @param path the original path
+ * @param strip the number of leading components to strip
+ * @return The stripped path
+ */
+ private static String stripPath(String path, int strip) {
+ int pos = 0;
+ while (pos < path.length() && strip > 0) {
+ if (path.charAt(pos) == '/') {
+ strip--;
+ }
+ pos++;
+ }
+ return path.substring(pos);
+ }
+
+ /**
+ * Extract the file path from a patch line starting with "--- " or "+++ " Returns null if the path
+ * is /dev/null, otherwise returns the extracted path if succeeded or throw an exception if
+ * failed.
+ */
+ private static String extractPath(String line, int strip, int loc) throws PatchFailedException {
+ // The line could look like:
+ // --- a/foo/bar.txt 2019-05-27 17:19:37.054593200 +0200
+ // +++ b/foo/bar.txt 2019-05-27 17:19:37.054593200 +0200
+ // If strip is 1, we want extract the file path as foo/bar.txt
+ Preconditions.checkArgument(line.startsWith("+++ ") || line.startsWith("--- "));
+ line = Iterables.get(Splitter.on('\t').split(line), 0);
+ if (line.length() > 4) {
+ String path = line.substring(4).trim();
+ if (path.equals("/dev/null")) {
+ return null;
+ }
+ path = stripPath(path, strip);
+ if (!path.isEmpty()) {
+ return path;
+ }
+ }
+ throw new PatchFailedException(
+ String.format(
+ "Cannot determine file name with strip = %d at line %d:\n%s", strip, loc, line));
+ }
+
+ private static Path getFilePath(String path, Path outputDirectory, int loc)
+ throws PatchFailedException {
+ if (path == null) {
+ return null;
+ }
+ Path filePath = outputDirectory.getRelative(path);
+ if (!filePath.startsWith(outputDirectory)) {
+ throw new PatchFailedException(
+ String.format(
+ "Cannot patch file outside of external repository (%s), file path = \"%s\" at line"
+ + " %d",
+ outputDirectory.getPathString(), path, loc));
+ }
+ return filePath;
+ }
+
+ private static void checkPatchContentIsComplete(
+ List<String> patchContent, ChunkHeader header, int oldLineCount, int newLineCount, int loc)
+ throws PatchFailedException {
+ // If the patchContent is not empty, it should have correct format.
+ if (!patchContent.isEmpty()) {
+ if (header == null) {
+ throw new PatchFailedException(
+ String.format(
+ "Looks like a unified diff at line %d, but no patch chunk was found.", loc));
+ }
+ Result result = header.check(oldLineCount, newLineCount);
+ // result will never be Result.Error here because it would have been throw in previous
+ // line already.
+ if (result == Result.CONTINUE) {
+ throw new PatchFailedException(
+ String.format("Expecting more chunk line at line %d", loc + patchContent.size()));
+ }
+ }
+ }
+
+ private static void checkFilesStatusForRenaming(
+ Path oldFile, Path newFile, String oldFileStr, String newFileStr, int loc)
+ throws PatchFailedException {
+ // If we're doing a renaming,
+ // old file should be specified and exists,
+ // new file should be specified but doesn't exist yet.
+ String oldFileError = "";
+ String newFileError = "";
+ if (oldFile == null) {
+ oldFileError = ", old file name (%s) is not specified";
+ } else if (!oldFile.exists()) {
+ oldFileError = String.format(", old file name (%s) doesn't exist", oldFileStr);
+ }
+ if (newFile == null) {
+ newFileError = ", new file name is not specified";
+ } else if (newFile.exists()) {
+ newFileError = String.format(", new file name (%s) already exists", newFileStr);
+ }
+ if (!oldFileError.isEmpty() || !newFileError.isEmpty()) {
+ throw new PatchFailedException(
+ String.format("Cannot rename file (near line %d)%s%s.", loc, oldFileError, newFileError));
+ }
+ }
+
+ private static void checkFilesStatusForPatching(
+ Patch<String> patch,
+ Path oldFile,
+ Path newFile,
+ String oldFileStr,
+ String newFileStr,
+ int loc)
+ throws PatchFailedException {
+ // At least one of oldFile or newFile should be specified.
+ if (oldFile == null && newFile == null) {
+ throw new PatchFailedException(
+ String.format(
+ "Wrong patch format near line %d, neither new file or old file are specified.", loc));
+ }
+
+ // Does this patch look like adding a new file.
+ boolean isAddFile =
+ patch.getDeltas().size() == 1
+ && patch.getDeltas().get(0).getOriginal().getLines().isEmpty();
+
+ // If this patch is not adding a new file,
+ // then either old file or new file should be specified and exists,
+ // if not we throw an error.
+ if (!isAddFile
+ && (oldFile == null || !oldFile.exists())
+ && (newFile == null || !newFile.exists())) {
+ String oldFileError;
+ String newFileError;
+ if (oldFile == null) {
+ oldFileError = ", old file name (%s) is not specified";
+ } else {
+ oldFileError = String.format(", old file name (%s) doesn't exist", oldFileStr);
+ }
+ if (newFile == null) {
+ newFileError = ", new file name is not specified";
+ } else {
+ newFileError = String.format(", new file name (%s) doesn't exist", newFileStr);
+ }
+ throw new PatchFailedException(
+ String.format(
+ "Cannot find file to patch (near line %d)%s%s.", loc, oldFileError, newFileError));
+ }
+ }
+
+ /**
+ * Apply a patch file under a directory
+ *
+ * @param patchFile the patch file to apply
+ * @param strip the number of leading components to strip from file path in the patch file
+ * @param outputDirectory the repository directory to apply the patch file
+ */
+ public static void apply(Path patchFile, int strip, Path outputDirectory)
+ throws IOException, PatchFailedException {
+ if (!patchFile.exists()) {
+ throw new PatchFailedException("Cannot find patch file: " + patchFile.getPathString());
+ }
+ List<String> patchFileLines = readFile(patchFile);
+ // Adding an extra line to make sure last chunk also gets applied.
+ patchFileLines.add("$");
+
+ boolean isGitDiff = false;
+ boolean hasRenameFrom = false;
+ boolean hasRenameTo = false;
+ boolean isReadingChunk = false;
+ List<String> patchContent = new ArrayList<>();
+ ChunkHeader header = null;
+ String oldFileStr = null;
+ String newFileStr = null;
+ Path oldFile = null;
+ Path newFile = null;
+ int oldLineCount = 0;
+ int newLineCount = 0;
+ Result result;
+
+ for (int i = 0; i < patchFileLines.size(); i++) {
+ String line = patchFileLines.get(i);
+ LineType type;
+ switch (type = getLineType(line, isReadingChunk, isGitDiff)) {
+ case OLD_FILE:
+ patchContent.add(line);
+ oldFileStr = extractPath(line, strip, i + 1);
+ oldFile = getFilePath(oldFileStr, outputDirectory, i + 1);
+ break;
+ case NEW_FILE:
+ patchContent.add(line);
+ newFileStr = extractPath(line, strip, i + 1);
+ newFile = getFilePath(newFileStr, outputDirectory, i + 1);
+ break;
+ case CHUNK_HEAD:
+ int pos = line.indexOf("@@", 2);
+ String headerStr = line.substring(0, pos + 2);
+ patchContent.add(headerStr);
+ header = new ChunkHeader(headerStr);
+ oldLineCount = 0;
+ newLineCount = 0;
+ isReadingChunk = true;
+ break;
+ case CHUNK_ADD:
+ newLineCount++;
+ patchContent.add(line);
+ result = header.check(oldLineCount, newLineCount);
+ if (result == Result.COMPLETE) {
+ isReadingChunk = false;
+ } else if (result == Result.ERROR) {
+ throw new PatchFailedException(
+ "Wrong chunk detected near line "
+ + (i + 1)
+ + ": "
+ + line
+ + ", does not expect an added line here.");
+ }
+ break;
+ case CHUNK_DEL:
+ oldLineCount++;
+ patchContent.add(line);
+ result = header.check(oldLineCount, newLineCount);
+ if (result == Result.COMPLETE) {
+ isReadingChunk = false;
+ } else if (result == Result.ERROR) {
+ throw new PatchFailedException(
+ "Wrong chunk detected near line "
+ + (i + 1)
+ + ": "
+ + line
+ + ", does not expect a deleted line here.");
+ }
+ break;
+ case CHUNK_EQL:
+ oldLineCount++;
+ newLineCount++;
+ patchContent.add(line);
+ result = header.check(oldLineCount, newLineCount);
+ if (result == Result.COMPLETE) {
+ isReadingChunk = false;
+ } else if (result == Result.ERROR) {
+ throw new PatchFailedException(
+ "Wrong chunk detected near line "
+ + (i + 1)
+ + ": "
+ + line
+ + ", does not expect a context line here.");
+ }
+ break;
+ case RENAME_FROM:
+ hasRenameFrom = true;
+ if (oldFileStr == null) {
+ // len("rename from ") == 12
+ oldFileStr = line.substring(12).trim();
+ if (oldFileStr.isEmpty()) {
+ throw new PatchFailedException(
+ String.format("Cannot determine file name from line %d:\n%s", i + 1, line));
+ }
+ oldFile = getFilePath(oldFileStr, outputDirectory, i + 1);
+ }
+ break;
+ case RENAME_TO:
+ hasRenameTo = true;
+ if (newFileStr == null) {
+ // len("rename to ") == 10
+ newFileStr = line.substring(10).trim();
+ if (newFileStr.isEmpty()) {
+ throw new PatchFailedException(
+ String.format("Cannot determine file name from line %d:\n%s", i + 1, line));
+ }
+ newFile = getFilePath(newFileStr, outputDirectory, i + 1);
+ }
+ break;
+ case GIT_LINE:
+ break;
+ case GIT_HEADER:
+ case UNKNOWN:
+ // A git header line or an unknown line should trigger an action to apply collected
+ // patch content to a file.
+
+ // Renaming is a git only format
+ boolean isRenaming = isGitDiff && hasRenameFrom && hasRenameTo;
+
+ if (!patchContent.isEmpty() || isRenaming) {
+ // We collected something useful, let's do some sanity checks before applying the patch.
+ int patchStartLocation = i + 1 - patchContent.size();
+
+ checkPatchContentIsComplete(
+ patchContent, header, oldLineCount, newLineCount, patchStartLocation);
+
+ if (isRenaming) {
+ checkFilesStatusForRenaming(
+ oldFile, newFile, oldFileStr, newFileStr, patchStartLocation);
+ }
+
+ Patch<String> patch = DiffUtils.parseUnifiedDiff(patchContent);
+ checkFilesStatusForPatching(
+ patch, oldFile, newFile, oldFileStr, newFileStr, patchStartLocation);
+
+ applyPatchToFile(patch, oldFile, newFile, isRenaming);
+ }
+
+ patchContent.clear();
+ header = null;
+ oldFileStr = null;
+ newFileStr = null;
+ oldFile = null;
+ newFile = null;
+ oldLineCount = 0;
+ newLineCount = 0;
+ isReadingChunk = false;
+ // If the new patch starts with "diff --git " then it's a git diff.
+ isGitDiff = type == LineType.GIT_HEADER;
+ hasRenameFrom = false;
+ hasRenameTo = false;
+ break;
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/repository/BUILD b/src/test/java/com/google/devtools/build/lib/bazel/repository/BUILD
index 623bb3f..e91b284 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/repository/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/bazel/repository/BUILD
@@ -53,6 +53,7 @@
"//src/test/java/com/google/devtools/build/lib:packages_testutil",
"//src/test/java/com/google/devtools/build/lib:test_runner",
"//src/test/java/com/google/devtools/build/lib:testutil",
+ "//third_party:diffutils",
"//third_party:guava",
"//third_party:guava-testlib",
"//third_party:jsr305",
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/repository/PatchUtilTest.java b/src/test/java/com/google/devtools/build/lib/bazel/repository/PatchUtilTest.java
new file mode 100644
index 0000000..1c85989
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/bazel/repository/PatchUtilTest.java
@@ -0,0 +1,549 @@
+// Copyright 2019 The Bazel Authors. All rights reserved.
+//
+// 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.devtools.build.lib.bazel.repository;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.testutil.MoreAsserts.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.testutil.Scratch;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+import difflib.PatchFailedException;
+import java.io.IOException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link PatchUtil}. */
+@RunWith(JUnit4.class)
+public class PatchUtilTest {
+
+ private FileSystem fs;
+ private Scratch scratch;
+ private Path root;
+
+ @Before
+ public final void initializeFileSystemAndDirectories() throws Exception {
+ fs = new InMemoryFileSystem();
+ scratch = new Scratch(fs, "/root");
+ root = scratch.dir("/root");
+ }
+
+ @Test
+ public void testAddFile() throws IOException, PatchFailedException {
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/newfile b/newfile",
+ "new file mode 100644",
+ "index 0000000..f742c88",
+ "--- /dev/null",
+ "+++ b/newfile",
+ "@@ -0,0 +1,2 @@",
+ "+I'm a new file",
+ "+hello, world",
+ "-- ",
+ "2.21.0.windows.1");
+ PatchUtil.apply(patchFile, 1, root);
+ Path newFile = root.getRelative("newfile");
+ ImmutableList<String> newFileContent = ImmutableList.of("I'm a new file", "hello, world");
+ assertThat(PatchUtil.readFile(newFile)).containsExactlyElementsIn(newFileContent);
+ }
+
+ @Test
+ public void testDeleteFile() throws IOException, PatchFailedException {
+ Path oldFile = scratch.file("/root/oldfile", "I'm an old file", "bye, world");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "--- a/oldfile",
+ "+++ /dev/null",
+ "@@ -1,2 +0,0 @@",
+ "-I'm an old file",
+ "-bye, world");
+ PatchUtil.apply(patchFile, 1, root);
+ assertThat(oldFile.exists()).isFalse();
+ }
+
+ @Test
+ public void testDeleteAllContentButNotFile() throws IOException, PatchFailedException {
+ // If newfile is not /dev/null, we don't delete the file even it's empty after patching,
+ // this is the behavior of patch command line tool.
+ Path oldFile = scratch.file("/root/oldfile", "I'm an old file", "bye, world");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "--- a/oldfile",
+ "+++ b/oldfile",
+ "@@ -1,2 +0,0 @@",
+ "-I'm an old file",
+ "-bye, world");
+ PatchUtil.apply(patchFile, 1, root);
+ assertThat(oldFile.exists()).isTrue();
+ assertThat(PatchUtil.readFile(oldFile)).isEmpty();
+ }
+
+ @Test
+ public void testApplyToOldFile() throws IOException, PatchFailedException {
+ // If both oldfile and newfile exist, we should patch the old file.
+ Path oldFile = scratch.file("/root/oldfile", "line one");
+ Path newFile = scratch.file("/root/newfile", "line one");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "--- oldfile",
+ "+++ newfile",
+ "@@ -1,1 +1,2 @@",
+ " line one",
+ "+line two");
+ PatchUtil.apply(patchFile, 0, root);
+ ImmutableList<String> newContent = ImmutableList.of("line one", "line two");
+ assertThat(PatchUtil.readFile(oldFile)).containsExactlyElementsIn(newContent);
+ // new file should not change
+ assertThat(PatchUtil.readFile(newFile)).containsExactly("line one");
+ }
+
+ @Test
+ public void testApplyToNewFile() throws IOException, PatchFailedException {
+ // If only newfile exists, we should patch the new file.
+ Path newFile = scratch.file("/root/newfile", "line one");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "--- oldfile",
+ "+++ newfile",
+ "@@ -1,1 +1,2 @@",
+ " line one",
+ "+line two");
+ PatchUtil.apply(patchFile, 0, root);
+ ImmutableList<String> newContent = ImmutableList.of("line one", "line two");
+ assertThat(PatchUtil.readFile(newFile)).containsExactlyElementsIn(newContent);
+ }
+
+ @Test
+ public void testGitFormatPatching() throws IOException, PatchFailedException {
+ Path foo =
+ scratch.file(
+ "/root/foo.cc",
+ "#include <stdio.h>",
+ "",
+ "void main(){",
+ " printf(\"Hello foo\");",
+ "}");
+ Path bar = scratch.file("/root/bar.cc", "void lib(){", " printf(\"Hello bar\");", "}");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "From d205551eab3350afdb380f90ef83442ffcc0e22b Mon Sep 17 00:00:00 2001",
+ "From: Yun Peng <pcloudy@google.com>",
+ "Date: Thu, 6 Jun 2019 11:34:08 +0200",
+ "Subject: [PATCH] 2",
+ "",
+ "---",
+ " bar.cc | 2 +-",
+ " foo.cc | 1 +",
+ " 2 files changed, 2 insertions(+), 1 deletion(-)",
+ "",
+ "diff --git a/bar.cc b/bar.cc",
+ "index e77137b..36dc9ab 100644",
+ "--- a/bar.cc",
+ "+++ b/bar.cc",
+ "@@ -1,3 +1,3 @@",
+ " void lib(){",
+ "- printf(\"Hello bar\");",
+ "+ printf(\"Hello patch\");",
+ " }",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "--- a/foo.cc",
+ "+++ b/foo.cc",
+ "@@ -2,4 +2,5 @@",
+ " ",
+ " void main(){",
+ " printf(\"Hello foo\");",
+ "+ printf(\"Hello from patch\");",
+ " }",
+ "-- ",
+ "2.21.0.windows.1",
+ "",
+ "");
+ PatchUtil.apply(patchFile, 1, root);
+ ImmutableList<String> newFoo =
+ ImmutableList.of(
+ "#include <stdio.h>",
+ "",
+ "void main(){",
+ " printf(\"Hello foo\");",
+ " printf(\"Hello from patch\");",
+ "}");
+ ImmutableList<String> newBar =
+ ImmutableList.of("void lib(){", " printf(\"Hello patch\");", "}");
+ assertThat(PatchUtil.readFile(foo)).containsExactlyElementsIn(newFoo);
+ assertThat(PatchUtil.readFile(bar)).containsExactlyElementsIn(newBar);
+ }
+
+ @Test
+ public void testGitFormatRenaming() throws IOException, PatchFailedException {
+ Path foo =
+ scratch.file(
+ "/root/foo.cc",
+ "#include <stdio.h>",
+ "",
+ "void main(){",
+ " printf(\"Hello foo\");",
+ "}");
+ Path bar = scratch.file("/root/bar.cc", "void lib(){", " printf(\"Hello bar\");", "}");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/bar.cc b/bar.cpp",
+ "similarity index 61%",
+ "rename from bar.cc",
+ "rename to bar.cpp",
+ "index e77137b..9e35ee4 100644",
+ "--- a/bar.cc",
+ "+++ b/bar.cpp",
+ "@@ -1,3 +1,4 @@",
+ " void lib(){",
+ " printf(\"Hello bar\");",
+ "+ printf(\"Hello cpp\");",
+ " }",
+ "diff --git a/foo.cc b/foo.cpp",
+ "similarity index 100%",
+ "rename from foo.cc",
+ "rename to foo.cpp");
+ PatchUtil.apply(patchFile, 1, root);
+ ImmutableList<String> newFoo =
+ ImmutableList.of("#include <stdio.h>", "", "void main(){", " printf(\"Hello foo\");", "}");
+ ImmutableList<String> newBar =
+ ImmutableList.of(
+ "void lib(){", " printf(\"Hello bar\");", " printf(\"Hello cpp\");", "}");
+ Path fooCpp = root.getRelative("foo.cpp");
+ Path barCpp = root.getRelative("bar.cpp");
+ assertThat(foo.exists()).isFalse();
+ assertThat(bar.exists()).isFalse();
+ assertThat(PatchUtil.readFile(fooCpp)).containsExactlyElementsIn(newFoo);
+ assertThat(PatchUtil.readFile(barCpp)).containsExactlyElementsIn(newBar);
+ }
+
+ @Test
+ public void testMatchWithOffset() throws IOException, PatchFailedException {
+ Path foo =
+ scratch.file(
+ "/root/foo.cc",
+ "#include <stdio.h>",
+ "",
+ "void main(){",
+ " printf(\"Hello foo\");",
+ "}");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "--- a/foo.cc",
+ "+++ b/foo.cc",
+ "@@ -6,4 +6,5 @@", // Should match with offset -4, original is "@@ -2,4 +2,5 @@"
+ " ",
+ " void main(){",
+ " printf(\"Hello foo\");",
+ "+ printf(\"Hello from patch\");",
+ " }");
+ PatchUtil.apply(patchFile, 1, root);
+ ImmutableList<String> newFoo =
+ ImmutableList.of(
+ "#include <stdio.h>",
+ "",
+ "void main(){",
+ " printf(\"Hello foo\");",
+ " printf(\"Hello from patch\");",
+ "}");
+ assertThat(PatchUtil.readFile(foo)).containsExactlyElementsIn(newFoo);
+ }
+
+ @Test
+ public void testMultipleChunksWithDifferentOffset() throws IOException, PatchFailedException {
+ Path foo =
+ scratch.file("/root/foo", "1", "3", "4", "5", "6", "7", "8", "9", "10", "11", "13", "14");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo b/foo",
+ "index c20ab12..b83bdb1 100644",
+ "--- a/foo",
+ "+++ b/foo",
+ "@@ -3,4 +3,5 @@", // Should match with offset -2, original is "@@ -1,4 +1,5 @@"
+ " 1",
+ "+2",
+ " 3",
+ " 4",
+ " 5",
+ "@@ -4,5 +5,6 @@", // Should match with offset 4, original is "@@ -8,4 +9,5 @@"
+ " 9",
+ " 10",
+ " 11",
+ "+12",
+ " 13",
+ " 14");
+ PatchUtil.apply(patchFile, 1, root);
+ ImmutableList<String> newFoo =
+ ImmutableList.of("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14");
+ assertThat(PatchUtil.readFile(foo)).containsExactlyElementsIn(newFoo);
+ }
+
+ @Test
+ public void testFailedToGetFileName() throws IOException {
+ scratch.file(
+ "/root/foo.cc", "#include <stdio.h>", "", "void main(){", " printf(\"Hello foo\");", "}");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "--- a/foo.cc",
+ "+++ b/foo.cc",
+ "@@ -2,4 +2,5 @@",
+ " ",
+ " void main(){",
+ " printf(\"Hello foo\");",
+ "+ printf(\"Hello from patch\");",
+ " }");
+ PatchFailedException expected =
+ assertThrows(
+ PatchFailedException.class,
+ () -> PatchUtil.apply(patchFile, 2, root)); // strip=2 is wrong
+ assertThat(expected)
+ .hasMessageThat()
+ .contains("Cannot determine file name with strip = 2 at line 3:\n--- a/foo.cc");
+ }
+
+ @Test
+ public void testPatchFileNotFound() {
+ PatchFailedException expected =
+ assertThrows(
+ PatchFailedException.class,
+ () -> PatchUtil.apply(root.getRelative("patchfile"), 1, root));
+ assertThat(expected).hasMessageThat().contains("Cannot find patch file: /root/patchfile");
+ }
+
+ @Test
+ public void testMissingBothOldAndNewFile() throws IOException {
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "@@ -2,4 +2,5 @@",
+ " ",
+ " void main(){",
+ " printf(\"Hello foo\");",
+ "+ printf(\"Hello from patch\");",
+ " }");
+ PatchFailedException expected =
+ assertThrows(PatchFailedException.class, () -> PatchUtil.apply(patchFile, 1, root));
+ assertThat(expected)
+ .hasMessageThat()
+ .contains("Wrong patch format near line 3, neither new file or old file are specified.");
+ }
+
+ @Test
+ public void testCannotFindFileToPatch() throws IOException {
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "--- a/foo.cc",
+ "+++ /dev/null",
+ "@@ -2,4 +2,5 @@",
+ " ",
+ " void main(){",
+ " printf(\"Hello foo\");",
+ "+ printf(\"Hello from patch\");",
+ " }");
+ PatchFailedException expected =
+ assertThrows(PatchFailedException.class, () -> PatchUtil.apply(patchFile, 1, root));
+ assertThat(expected)
+ .hasMessageThat()
+ .contains(
+ "Cannot find file to patch (near line 3), old file name (foo.cc) doesn't exist, "
+ + "new file name is not specified.");
+ }
+
+ @Test
+ public void testCannotRenameFile() throws IOException {
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/bar.cc b/bar.cpp",
+ "similarity index 61%",
+ "rename from bar.cc",
+ "rename to bar.cpp",
+ "index e77137b..9e35ee4 100644",
+ "--- a/bar.cc",
+ "+++ b/bar.cpp",
+ "@@ -1,3 +1,4 @@",
+ " void lib(){",
+ " printf(\"Hello bar\");",
+ "+ printf(\"Hello cpp\");",
+ " }",
+ "diff --git a/foo.cc b/foo.cpp",
+ "similarity index 100%",
+ "rename from foo.cc",
+ "rename to foo.cpp");
+
+ PatchFailedException expected;
+ expected = assertThrows(PatchFailedException.class, () -> PatchUtil.apply(patchFile, 1, root));
+ assertThat(expected)
+ .hasMessageThat()
+ .contains("Cannot rename file (near line 6), old file name (bar.cc) doesn't exist.");
+
+ scratch.file("/root/bar.cc", "void lib(){", " printf(\"Hello bar\");", "}");
+ scratch.file("/root/foo.cc");
+ scratch.file("/root/foo.cpp");
+
+ expected = assertThrows(PatchFailedException.class, () -> PatchUtil.apply(patchFile, 1, root));
+ assertThat(expected)
+ .hasMessageThat()
+ .contains("Cannot rename file (near line 17), new file name (foo.cpp) already exists.");
+ }
+
+ @Test
+ public void testPatchOutsideOfRepository() throws IOException {
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "--- a/../other_root/foo.cc",
+ "+++ b/../other_root/foo.cc",
+ "@@ -2,4 +2,5 @@",
+ " ",
+ " void main(){",
+ " printf(\"Hello foo\");",
+ "+ printf(\"Hello from patch\");",
+ " }");
+ PatchFailedException expected =
+ assertThrows(PatchFailedException.class, () -> PatchUtil.apply(patchFile, 1, root));
+ assertThat(expected)
+ .hasMessageThat()
+ .contains(
+ "Cannot patch file outside of external repository (/root), "
+ + "file path = \"../other_root/foo.cc\" at line 3");
+ }
+
+ @Test
+ public void testChunkDoesNotMatch() throws IOException {
+ scratch.file(
+ "/root/foo.cc", "#include <stdio.h>", "", "void main(){", " printf(\"Hello foo\");", "}");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "--- a/foo.cc",
+ "+++ b/foo.cc",
+ "@@ -2,4 +2,5 @@",
+ " ",
+ " void main(){",
+ " printf(\"Hello bar\");", // Should be "Hello foo"
+ "+ printf(\"Hello from patch\");",
+ " }");
+ PatchFailedException expected =
+ assertThrows(PatchFailedException.class, () -> PatchUtil.apply(patchFile, 1, root));
+ assertThat(expected)
+ .hasMessageThat()
+ .contains(
+ "Incorrect Chunk: the chunk content doesn't match the target\n"
+ + "Failed to apply delta:\n"
+ + " [ChangeDelta, position: 1, lines: [, void main(){, printf(\"Hello"
+ + " bar\");, }] to [, void main(){, printf(\"Hello bar\");, printf(\"Hello"
+ + " from patch\");, }]]");
+ }
+
+ @Test
+ public void testWrongChunkFormat1() throws IOException {
+ scratch.file(
+ "/root/foo.cc", "#include <stdio.h>", "", "void main(){", " printf(\"Hello foo\");", "}");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "--- a/foo.cc",
+ "+++ b/foo.cc",
+ "@@ -2,4 +2,5 @@",
+ " ",
+ " void main(){",
+ " printf(\"Hello foo\");",
+ "+ printf(\"Hello from patch\");",
+ "+", // Adding this line will cause the chunk body not matching the header "@@ -2,4 +2,5
+ // @@"
+ " }");
+ PatchFailedException expected =
+ assertThrows(PatchFailedException.class, () -> PatchUtil.apply(patchFile, 1, root));
+ assertThat(expected)
+ .hasMessageThat()
+ .contains("Wrong chunk detected near line 11: }, does not expect a context line here.");
+ }
+
+ @Test
+ public void testWrongChunkFormat2() throws IOException {
+ scratch.file(
+ "/root/foo.cc", "#include <stdio.h>", "", "void main(){", " printf(\"Hello foo\");", "}");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "--- a/foo.cc",
+ "+++ b/foo.cc",
+ "@@ -2,4 +2,5 @@",
+ " ",
+ " void main(){",
+ " printf(\"Hello foo\");",
+ "+ printf(\"Hello from patch\");");
+ PatchFailedException expected =
+ assertThrows(PatchFailedException.class, () -> PatchUtil.apply(patchFile, 1, root));
+ assertThat(expected).hasMessageThat().contains("Expecting more chunk line at line 10");
+ }
+
+ @Test
+ public void testWrongChunkFormat3() throws IOException {
+ scratch.file(
+ "/root/foo.cc", "#include <stdio.h>", "", "void main(){", " printf(\"Hello foo\");", "}");
+ Path patchFile =
+ scratch.file(
+ "/root/patchfile",
+ "diff --git a/foo.cc b/foo.cc",
+ "index f3008f9..ec4aaa0 100644",
+ "--- a/foo.cc",
+ "+++ b/foo.cc",
+ // Missing @@ -l,s +l,s @@ line
+ " ",
+ " void main(){",
+ " printf(\"Hello foo\");",
+ "+ printf(\"Hello from patch\");",
+ " }");
+ PatchFailedException expected =
+ assertThrows(PatchFailedException.class, () -> PatchUtil.apply(patchFile, 1, root));
+ assertThat(expected)
+ .hasMessageThat()
+ .contains("Looks like a unified diff at line 3, but no patch chunk was found.");
+ }
+}