Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions jgit/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ plugins {
dependencies {
implementation("com.googlecode.javaewah:JavaEWAH:1.1.13")
compileOnly("org.slf4j:slf4j-api:1.7.36")
testImplementation("org.assertj:assertj-core:3.26.3") {
version { strictly("3.26.3") }
}
testRuntimeOnly("org.slf4j:slf4j-simple:1.7.36")
}

java {
Expand Down
32 changes: 31 additions & 1 deletion jgit/src/main/java/org/openrewrite/jgit/api/ApplyCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,8 @@ private boolean canApplyAt(List<ByteBuffer> hunkLines,
case ' ':
case '-':
if (pos >= limit
|| !newLines.get(pos).equals(slice(hunkLine, 1))) {
|| !equalsIgnoringTrailingWhitespace(
newLines.get(pos), slice(hunkLine, 1))) {
return false;
}
pos++;
Expand All @@ -777,6 +778,35 @@ private ByteBuffer slice(ByteBuffer b, int off) {
return ByteBuffer.wrap(b.array(), newOffset, b.limit() - newOffset);
}

private static boolean equalsIgnoringTrailingWhitespace(ByteBuffer a,
ByteBuffer b) {
int aEnd = a.limit();
while (aEnd > a.position()
&& isWhitespace(a.array()[aEnd - 1])) {
aEnd--;
}
int bEnd = b.limit();
while (bEnd > b.position()
&& isWhitespace(b.array()[bEnd - 1])) {
bEnd--;
}
int aLen = aEnd - a.position();
int bLen = bEnd - b.position();
if (aLen != bLen) {
return false;
}
for (int i = 0; i < aLen; i++) {
if (a.array()[a.position() + i] != b.array()[b.position() + i]) {
return false;
}
}
return true;
}

private static boolean isWhitespace(byte b) {
return b == ' ' || b == '\t' || b == '\r';
}

private boolean isNoNewlineAtEndOfFile(FileHeader fh) {
List<? extends HunkHeader> hunks = fh.getHunks();
if (hunks == null || hunks.isEmpty()) {
Expand Down
3 changes: 3 additions & 0 deletions jgit/src/main/java/org/openrewrite/jgit/patch/FileHeader.java
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,9 @@ private String parseName(String expect, int ptr, int end) {
r = decode(UTF_8, buf, ptr, tab - 1);
}

if (r.endsWith("\r")) {
r = r.substring(0, r.length() - 1);
}
if (r.equals(DEV_NULL))
r = DEV_NULL;
return r;
Expand Down
216 changes: 216 additions & 0 deletions jgit/src/test/java/org/openrewrite/jgit/api/ApplyCommandTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/*
* Copyright (C) 2026, OpenRewrite and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Distribution License v. 1.0 which is available at
* https://www.eclipse.org/org/documents/edl-v10.php.
*
* SPDX-License-Identifier: BSD-3-Clause
*/
package org.openrewrite.jgit.api;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openrewrite.jgit.api.errors.PatchApplyException;
import org.openrewrite.jgit.lib.Repository;
import org.openrewrite.jgit.util.FileUtils;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
* Tests for {@link ApplyCommand}, focusing on multi-hunk patch application.
*/
public class ApplyCommandTest {

private Repository db;
private Git git;
private File trash;

@BeforeEach
public void setUp() throws Exception {
trash = Files.createTempDirectory("jgit-apply-test").toFile();
git = Git.init().setDirectory(trash).call();
db = git.getRepository();
}

@AfterEach
public void tearDown() throws Exception {
if (db != null) {
db.close();
}
if (trash != null) {
FileUtils.delete(trash, FileUtils.RECURSIVE | FileUtils.RETRY);
}
}

/**
* Test applying a multi-hunk patch where both the file and patch use LF line endings.
*/
@Test
public void testMultiHunkPatchWithLF() throws Exception {
StringBuilder fileContent = new StringBuilder();
for (int i = 1; i <= 150; i++) {
fileContent.append("Line ").append(i).append('\n');
}
writeFileAndCommit("test.txt", fileContent.toString());

String patch = multiHunkPatch("\n");

ApplyResult result = applyPatch(patch);
assertThat(result.getUpdatedFiles()).isNotEmpty();

String resultContent = readFile("test.txt");
assertThat(resultContent).contains("New Line A1", "New Line A6",
"New Line B");
}

/**
* Test applying a patch with CRLF line endings to a file with LF line
* endings.
*/
@Test
public void testMultiHunkPatchWithCRLFPatchAndLFFile() throws Exception {
StringBuilder fileContent = new StringBuilder();
for (int i = 1; i <= 150; i++) {
fileContent.append("Line ").append(i).append('\n');
}
writeFileAndCommit("test.txt", fileContent.toString());

String patch = multiHunkPatch("\r\n");

ApplyResult result = applyPatch(patch);
assertThat(result.getUpdatedFiles()).isNotEmpty();

String resultContent = readFile("test.txt");
assertThat(resultContent).contains("New Line A1", "New Line B");
}

/**
* Test applying a patch with LF line endings to a file with CRLF line
* endings.
*/
@Test
public void testMultiHunkPatchWithLFPatchAndCRLFFile() throws Exception {
StringBuilder fileContent = new StringBuilder();
for (int i = 1; i <= 150; i++) {
fileContent.append("Line ").append(i).append("\r\n");
}
writeFileAndCommit("test.txt", fileContent.toString());

String patch = multiHunkPatch("\n");

ApplyResult result = applyPatch(patch);
assertThat(result.getUpdatedFiles()).isNotEmpty();

String resultContent = readFile("test.txt");
assertThat(resultContent).contains("New Line A1", "New Line B");
}

/**
* Test applying a patch where the file has trailing whitespace on some
* context lines but the patch does not.
*/
@Test
public void testMultiHunkPatchWithTrailingWhitespaceDifference()
throws Exception {
StringBuilder fileContent = new StringBuilder();
for (int i = 1; i <= 150; i++) {
if (i == 118 || i == 119 || i == 120) {
fileContent.append("Line ").append(i).append(" \n");
} else {
fileContent.append("Line ").append(i).append('\n');
}
}
writeFileAndCommit("test.txt", fileContent.toString());

String patch = multiHunkPatch("\n");

ApplyResult result = applyPatch(patch);
assertThat(result.getUpdatedFiles()).isNotEmpty();

String resultContent = readFile("test.txt");
assertThat(resultContent).contains("New Line A1", "New Line B");
}

/**
* Test that a patch with genuinely mismatched context lines is still
* rejected.
*/
@Test
public void testMultiHunkPatchWithMismatchedContextIsRejected()
throws Exception {
StringBuilder fileContent = new StringBuilder();
for (int i = 1; i <= 150; i++) {
fileContent.append("Line ").append(i).append('\n');
}
// Replace line 119 with completely different content
String content = fileContent.toString()
.replace("Line 119\n", "DIFFERENT CONTENT\n");
writeFileAndCommit("test.txt", content);

String patch = multiHunkPatch("\n");

assertThatThrownBy(() -> applyPatch(patch))
.isInstanceOf(PatchApplyException.class)
.hasMessageContaining("hunk");
}

private String multiHunkPatch(String eol) {
return "diff --git a/test.txt b/test.txt" + eol
+ "--- a/test.txt" + eol
+ "+++ b/test.txt" + eol
+ "@@ -10,6 +10,12 @@" + eol
+ " Line 10" + eol
+ " Line 11" + eol
+ " Line 12" + eol
+ "+New Line A1" + eol
+ "+New Line A2" + eol
+ "+New Line A3" + eol
+ "+New Line A4" + eol
+ "+New Line A5" + eol
+ "+New Line A6" + eol
+ " Line 13" + eol
+ " Line 14" + eol
+ " Line 15" + eol
+ "@@ -118,6 +124,7 @@" + eol
+ " Line 118" + eol
+ " Line 119" + eol
+ " Line 120" + eol
+ "+New Line B" + eol
+ " Line 121" + eol
+ " Line 122" + eol
+ " Line 123" + eol;
}

private void writeFileAndCommit(String name, String content)
throws Exception {
File f = new File(trash, name);
try (FileOutputStream fos = new FileOutputStream(f)) {
fos.write(content.getBytes(StandardCharsets.UTF_8));
}
git.add().addFilepattern(name).call();
git.commit().setMessage("initial").call();
}

private ApplyResult applyPatch(String patch) throws Exception {
InputStream in = new ByteArrayInputStream(
patch.getBytes(StandardCharsets.UTF_8));
return git.apply().setPatch(in).call();
}

private String readFile(String name) throws IOException {
File f = new File(trash, name);
return new String(Files.readAllBytes(f.toPath()),
StandardCharsets.UTF_8);
}
}
Loading