Skip to content

Commit 37244d5

Browse files
authored
Fix hunk apply failure caused by trailing whitespace mismatch (#10)
* Fix hunk apply failure caused by trailing whitespace mismatch * Improve test coverage: migrate to AssertJ and add negative test Switch ApplyCommandTest from JUnit assertTrue to AssertJ assertions for better failure messages. Add negative test to verify genuinely mismatched context lines are still rejected. Extract multiHunkPatch helper method.
1 parent a70606c commit 37244d5

4 files changed

Lines changed: 254 additions & 1 deletion

File tree

jgit/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ plugins {
55
dependencies {
66
implementation("com.googlecode.javaewah:JavaEWAH:1.1.13")
77
compileOnly("org.slf4j:slf4j-api:1.7.36")
8+
testImplementation("org.assertj:assertj-core:3.26.3") {
9+
version { strictly("3.26.3") }
10+
}
11+
testRuntimeOnly("org.slf4j:slf4j-simple:1.7.36")
812
}
913

1014
java {

jgit/src/main/java/org/openrewrite/jgit/api/ApplyCommand.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,8 @@ private boolean canApplyAt(List<ByteBuffer> hunkLines,
760760
case ' ':
761761
case '-':
762762
if (pos >= limit
763-
|| !newLines.get(pos).equals(slice(hunkLine, 1))) {
763+
|| !equalsIgnoringTrailingWhitespace(
764+
newLines.get(pos), slice(hunkLine, 1))) {
764765
return false;
765766
}
766767
pos++;
@@ -777,6 +778,35 @@ private ByteBuffer slice(ByteBuffer b, int off) {
777778
return ByteBuffer.wrap(b.array(), newOffset, b.limit() - newOffset);
778779
}
779780

781+
private static boolean equalsIgnoringTrailingWhitespace(ByteBuffer a,
782+
ByteBuffer b) {
783+
int aEnd = a.limit();
784+
while (aEnd > a.position()
785+
&& isWhitespace(a.array()[aEnd - 1])) {
786+
aEnd--;
787+
}
788+
int bEnd = b.limit();
789+
while (bEnd > b.position()
790+
&& isWhitespace(b.array()[bEnd - 1])) {
791+
bEnd--;
792+
}
793+
int aLen = aEnd - a.position();
794+
int bLen = bEnd - b.position();
795+
if (aLen != bLen) {
796+
return false;
797+
}
798+
for (int i = 0; i < aLen; i++) {
799+
if (a.array()[a.position() + i] != b.array()[b.position() + i]) {
800+
return false;
801+
}
802+
}
803+
return true;
804+
}
805+
806+
private static boolean isWhitespace(byte b) {
807+
return b == ' ' || b == '\t' || b == '\r';
808+
}
809+
780810
private boolean isNoNewlineAtEndOfFile(FileHeader fh) {
781811
List<? extends HunkHeader> hunks = fh.getHunks();
782812
if (hunks == null || hunks.isEmpty()) {

jgit/src/main/java/org/openrewrite/jgit/patch/FileHeader.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,9 @@ private String parseName(String expect, int ptr, int end) {
556556
r = decode(UTF_8, buf, ptr, tab - 1);
557557
}
558558

559+
if (r.endsWith("\r")) {
560+
r = r.substring(0, r.length() - 1);
561+
}
559562
if (r.equals(DEV_NULL))
560563
r = DEV_NULL;
561564
return r;
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
* Copyright (C) 2026, OpenRewrite and others
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Distribution License v. 1.0 which is available at
6+
* https://www.eclipse.org/org/documents/edl-v10.php.
7+
*
8+
* SPDX-License-Identifier: BSD-3-Clause
9+
*/
10+
package org.openrewrite.jgit.api;
11+
12+
import org.junit.jupiter.api.AfterEach;
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.Test;
15+
import org.openrewrite.jgit.api.errors.PatchApplyException;
16+
import org.openrewrite.jgit.lib.Repository;
17+
import org.openrewrite.jgit.util.FileUtils;
18+
19+
import java.io.ByteArrayInputStream;
20+
import java.io.File;
21+
import java.io.FileOutputStream;
22+
import java.io.IOException;
23+
import java.io.InputStream;
24+
import java.nio.charset.StandardCharsets;
25+
import java.nio.file.Files;
26+
27+
import static org.assertj.core.api.Assertions.assertThat;
28+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
29+
30+
/**
31+
* Tests for {@link ApplyCommand}, focusing on multi-hunk patch application.
32+
*/
33+
public class ApplyCommandTest {
34+
35+
private Repository db;
36+
private Git git;
37+
private File trash;
38+
39+
@BeforeEach
40+
public void setUp() throws Exception {
41+
trash = Files.createTempDirectory("jgit-apply-test").toFile();
42+
git = Git.init().setDirectory(trash).call();
43+
db = git.getRepository();
44+
}
45+
46+
@AfterEach
47+
public void tearDown() throws Exception {
48+
if (db != null) {
49+
db.close();
50+
}
51+
if (trash != null) {
52+
FileUtils.delete(trash, FileUtils.RECURSIVE | FileUtils.RETRY);
53+
}
54+
}
55+
56+
/**
57+
* Test applying a multi-hunk patch where both the file and patch use LF line endings.
58+
*/
59+
@Test
60+
public void testMultiHunkPatchWithLF() throws Exception {
61+
StringBuilder fileContent = new StringBuilder();
62+
for (int i = 1; i <= 150; i++) {
63+
fileContent.append("Line ").append(i).append('\n');
64+
}
65+
writeFileAndCommit("test.txt", fileContent.toString());
66+
67+
String patch = multiHunkPatch("\n");
68+
69+
ApplyResult result = applyPatch(patch);
70+
assertThat(result.getUpdatedFiles()).isNotEmpty();
71+
72+
String resultContent = readFile("test.txt");
73+
assertThat(resultContent).contains("New Line A1", "New Line A6",
74+
"New Line B");
75+
}
76+
77+
/**
78+
* Test applying a patch with CRLF line endings to a file with LF line
79+
* endings.
80+
*/
81+
@Test
82+
public void testMultiHunkPatchWithCRLFPatchAndLFFile() throws Exception {
83+
StringBuilder fileContent = new StringBuilder();
84+
for (int i = 1; i <= 150; i++) {
85+
fileContent.append("Line ").append(i).append('\n');
86+
}
87+
writeFileAndCommit("test.txt", fileContent.toString());
88+
89+
String patch = multiHunkPatch("\r\n");
90+
91+
ApplyResult result = applyPatch(patch);
92+
assertThat(result.getUpdatedFiles()).isNotEmpty();
93+
94+
String resultContent = readFile("test.txt");
95+
assertThat(resultContent).contains("New Line A1", "New Line B");
96+
}
97+
98+
/**
99+
* Test applying a patch with LF line endings to a file with CRLF line
100+
* endings.
101+
*/
102+
@Test
103+
public void testMultiHunkPatchWithLFPatchAndCRLFFile() throws Exception {
104+
StringBuilder fileContent = new StringBuilder();
105+
for (int i = 1; i <= 150; i++) {
106+
fileContent.append("Line ").append(i).append("\r\n");
107+
}
108+
writeFileAndCommit("test.txt", fileContent.toString());
109+
110+
String patch = multiHunkPatch("\n");
111+
112+
ApplyResult result = applyPatch(patch);
113+
assertThat(result.getUpdatedFiles()).isNotEmpty();
114+
115+
String resultContent = readFile("test.txt");
116+
assertThat(resultContent).contains("New Line A1", "New Line B");
117+
}
118+
119+
/**
120+
* Test applying a patch where the file has trailing whitespace on some
121+
* context lines but the patch does not.
122+
*/
123+
@Test
124+
public void testMultiHunkPatchWithTrailingWhitespaceDifference()
125+
throws Exception {
126+
StringBuilder fileContent = new StringBuilder();
127+
for (int i = 1; i <= 150; i++) {
128+
if (i == 118 || i == 119 || i == 120) {
129+
fileContent.append("Line ").append(i).append(" \n");
130+
} else {
131+
fileContent.append("Line ").append(i).append('\n');
132+
}
133+
}
134+
writeFileAndCommit("test.txt", fileContent.toString());
135+
136+
String patch = multiHunkPatch("\n");
137+
138+
ApplyResult result = applyPatch(patch);
139+
assertThat(result.getUpdatedFiles()).isNotEmpty();
140+
141+
String resultContent = readFile("test.txt");
142+
assertThat(resultContent).contains("New Line A1", "New Line B");
143+
}
144+
145+
/**
146+
* Test that a patch with genuinely mismatched context lines is still
147+
* rejected.
148+
*/
149+
@Test
150+
public void testMultiHunkPatchWithMismatchedContextIsRejected()
151+
throws Exception {
152+
StringBuilder fileContent = new StringBuilder();
153+
for (int i = 1; i <= 150; i++) {
154+
fileContent.append("Line ").append(i).append('\n');
155+
}
156+
// Replace line 119 with completely different content
157+
String content = fileContent.toString()
158+
.replace("Line 119\n", "DIFFERENT CONTENT\n");
159+
writeFileAndCommit("test.txt", content);
160+
161+
String patch = multiHunkPatch("\n");
162+
163+
assertThatThrownBy(() -> applyPatch(patch))
164+
.isInstanceOf(PatchApplyException.class)
165+
.hasMessageContaining("hunk");
166+
}
167+
168+
private String multiHunkPatch(String eol) {
169+
return "diff --git a/test.txt b/test.txt" + eol
170+
+ "--- a/test.txt" + eol
171+
+ "+++ b/test.txt" + eol
172+
+ "@@ -10,6 +10,12 @@" + eol
173+
+ " Line 10" + eol
174+
+ " Line 11" + eol
175+
+ " Line 12" + eol
176+
+ "+New Line A1" + eol
177+
+ "+New Line A2" + eol
178+
+ "+New Line A3" + eol
179+
+ "+New Line A4" + eol
180+
+ "+New Line A5" + eol
181+
+ "+New Line A6" + eol
182+
+ " Line 13" + eol
183+
+ " Line 14" + eol
184+
+ " Line 15" + eol
185+
+ "@@ -118,6 +124,7 @@" + eol
186+
+ " Line 118" + eol
187+
+ " Line 119" + eol
188+
+ " Line 120" + eol
189+
+ "+New Line B" + eol
190+
+ " Line 121" + eol
191+
+ " Line 122" + eol
192+
+ " Line 123" + eol;
193+
}
194+
195+
private void writeFileAndCommit(String name, String content)
196+
throws Exception {
197+
File f = new File(trash, name);
198+
try (FileOutputStream fos = new FileOutputStream(f)) {
199+
fos.write(content.getBytes(StandardCharsets.UTF_8));
200+
}
201+
git.add().addFilepattern(name).call();
202+
git.commit().setMessage("initial").call();
203+
}
204+
205+
private ApplyResult applyPatch(String patch) throws Exception {
206+
InputStream in = new ByteArrayInputStream(
207+
patch.getBytes(StandardCharsets.UTF_8));
208+
return git.apply().setPatch(in).call();
209+
}
210+
211+
private String readFile(String name) throws IOException {
212+
File f = new File(trash, name);
213+
return new String(Files.readAllBytes(f.toPath()),
214+
StandardCharsets.UTF_8);
215+
}
216+
}

0 commit comments

Comments
 (0)