From 691b3167173ad1e933d3a30e846ebb0c288e347b Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Tue, 6 Oct 2026 07:47:26 +0000 Subject: [PATCH] fix(patch_set): accept CRLF line endings in git extended headers GitHeader::parse extracts header values (file modes, rename/copy paths, the diff --git line) via strip_line_ending, which stripped only the trailing \n. In a patch file saved with CRLF line endings (e.g. on Windows) every extracted value therefore kept its trailing \r, and parsing a mode header failed outright: error parsing patches at byte 0: invalid file mode: 100644\r Strip a trailing \r as well, mirroring how GNU patch strips trailing CRs from CRLF patches (the behavior the TODO on strip_line_ending already pointed at). This only affects git extended header lines; hunk content is handled by the patch parser itself. Downstream report: pnpm/pnpm#16641, where a CRLF patch file that creates a new file fails with ERR_PNPM_INVALID_PATCH on pnpm 12 (pnpm parses patches with diffy's PatchSet::parse). Tests: crlf_new_file_with_content and crlf_mode_only_change fail on the unpatched parser with InvalidFileMode("100644\r") and pass with this change; the full suite (131 lib tests) passes. --- src/patch_set/parse.rs | 15 ++++++++----- src/patch_set/tests.rs | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/patch_set/parse.rs b/src/patch_set/parse.rs index 8facc78..4557eaf 100644 --- a/src/patch_set/parse.rs +++ b/src/patch_set/parse.rs @@ -821,12 +821,17 @@ fn extract_file_op_unidiff<'a, T: Text + ?Sized>( } } -/// Strips the trailing `\n` from a line yielded by [`Text::lines`]. +/// Strips the trailing line ending from a line yielded by [`Text::lines`]. /// /// [`Text::lines`] includes line endings; strip for matching. fn strip_line_ending(line: &T) -> &T { - // TODO: GNU patch strips trailing CRs from CRLF patches automatically. - // We should consider adding compat tests for GNU patch. - // And `git apply` seems to reject. Worth adding tests as well. - line.strip_suffix("\n").unwrap_or(line) + // GNU patch strips trailing CRs from CRLF patches automatically. + // Mirror that for header lines so a patch file saved with CRLF line + // endings (e.g. on Windows) parses the same as its LF equivalent: + // without this, extracted values such as the file mode keep their + // trailing `\r` and fail to parse (`invalid file mode: 100644\r`). + // Note `git apply` seems to reject CRLF patches. Worth adding tests + // for both behaviors as well. + let line = line.strip_suffix("\n").unwrap_or(line); + line.strip_suffix("\r").unwrap_or(line) } diff --git a/src/patch_set/tests.rs b/src/patch_set/tests.rs index 8d35809..8a4c9bd 100644 --- a/src/patch_set/tests.rs +++ b/src/patch_set/tests.rs @@ -759,6 +759,54 @@ index 0000000..abc1234 ); } + /// New file with content, with CRLF line endings throughout (e.g. a + /// patch file saved on Windows). The extended header values must not + /// keep the trailing `\r`: `new file mode 100644\r` previously failed + /// with `invalid file mode: 100644\r`. + #[test] + fn crlf_new_file_with_content() { + let input = "\ +diff --git a/new.rs b/new.rs +new file mode 100644 +index 0000000..abc1234 +--- /dev/null ++++ b/new.rs +@@ -0,0 +1 @@ ++hello +" + .replace('\n', "\r\n"); + let patches = parse_gitdiff(&input); + assert_eq!(patches.len(), 1); + assert!(patches[0].operation().is_create()); + assert_eq!( + patches[0].new_mode(), + Some(&super::super::FileMode::Regular), + ); + } + + /// Mode-only change with CRLF line endings: both `old mode` and + /// `new mode` values must parse despite the trailing `\r`. + #[test] + fn crlf_mode_only_change() { + let input = "\ +diff --git a/script.sh b/script.sh +old mode 100644 +new mode 100755 +" + .replace('\n', "\r\n"); + let patches = parse_gitdiff(&input); + assert_eq!(patches.len(), 1); + assert!(patches[0].operation().is_modify()); + assert_eq!( + patches[0].old_mode(), + Some(&super::super::FileMode::Regular), + ); + assert_eq!( + patches[0].new_mode(), + Some(&super::super::FileMode::Executable), + ); + } + /// `diff --git` line with no-prefix paths (`git diff --no-prefix`). /// Fallback path parsing works when ---/+++ are absent. #[test]