Skip to content

fix: allow parsing empty context lines at the very end of unified diffs#690

Open
rtritto wants to merge 1 commit into
kpdecker:masterfrom
rtritto:empty-lines
Open

fix: allow parsing empty context lines at the very end of unified diffs#690
rtritto wants to merge 1 commit into
kpdecker:masterfrom
rtritto:empty-lines

Conversation

@rtritto
Copy link
Copy Markdown

@rtritto rtritto commented May 23, 2026

When parsing unified diff files, empty context lines (which should technically consist of a single space " ") are often inadvertently truncated to completely empty strings ("") due to code editors trimming trailing whitespace or formatting tools removing extraneous spaces.

Currently, the parsePatch (specifically parseHunk) function successfully forgives and maps these empty strings back to a space token (' '), except when the empty string happens to be the last line in the diffstr array:

// Before
const operation = (diffstr[i].length == 0 && i != (diffstr.length - 1)) ? ' ' : diffstr[i][0];

If a patch file ends with a trailing newline, uniDiff.split(/\n/) yields "" as the last element of the array. If a hunk still expects one last context line at that exact end-of-file position, i points to this final "" string. Because of the i != (diffstr.length - 1) check, it falls back to diffstr[i][0], which evaluates to undefined.

Since undefined is not a valid operation (+, -, , \), parseHunk throws an opaque error:
Error: Hunk at line X contained invalid line

Changes Made:

Removed the boundary exception && i != (diffstr.length - 1) condition. Empty strings are now globally and safely interpreted as empty context lines regardless of their index in the diff string.

// After
const operation = diffstr[i].length == 0 ? ' ' : diffstr[i][0];

Why this fixes real-world use cases:

This minor tweak prevents runtime exceptions when using applyPatch() on valid patch files generated by package managers (like yarn patch or pnpm patch) or saved via code editors, where a hunk requires an empty contextual line extending to the very end of the file.

Copilot AI review requested due to automatic review settings May 23, 2026 08:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adjusts how parsePatch determines the hunk line “operation” for empty strings while iterating through a unified diff.

Changes:

  • Simplifies operation detection by treating empty lines as context (' ') unconditionally.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/patch/parse.ts
Comment on lines +470 to 472
const operation = diffstr[i].length == 0 ? ' ' : diffstr[i][0];
if (operation === '+' || operation === '-' || operation === ' ' || operation === '\\') {
hunk.lines.push(diffstr[i]);
@rtritto
Copy link
Copy Markdown
Author

rtritto commented May 23, 2026

FYI @ExplodingCabbage

@ExplodingCabbage
Copy link
Copy Markdown
Collaborator

This minor tweak prevents runtime exceptions when using applyPatch() on valid patch files generated by package managers (like yarn patch

I can't repro this; if I use yarn patch (with Yarn 4.12.0, which is what jsdiff is currently on) to generate a patch that ends with a blank context line, yarn generates a patch whose last three characters are newline-space-newline, as you'd expect.

Do you have exact repro steps to cause an existing patch-generating tool outputting a patch that applyPatch can't handle without this fix? (Let me know what versions you're using since this might be relevant.)

(It may be worth applying this change even if you are mistaken about yarn and pnpm, for consistency with non-trailing empty context lines and to handle the case where trimming is done by a text editor. I'll have a look and think about it. But would like to understand either way whether there really is an even stronger case for this based on yarn and pnpm emitting patches we currently consider malformed, as you suggest.)

@ExplodingCabbage
Copy link
Copy Markdown
Collaborator

Hmm... doesn't this change actually have the effect of letting us imagine an empty context line AFTER the final newline of a patch? That is, it's not letting us parse a patch like this, which actually we can already parse fine (formatted here as a JS template string, for ease of seeing exactly what newlines are present)...

`--- test1.txt
+++ test2.txt
@@ -1,5 +1,5 @@
 line1

-line3
+line3b
 line4

`

but is actually letting us parse one like THIS:

`--- test1.txt
+++ test2.txt
@@ -1,5 +1,5 @@
 line1

-line3
+line3b
 line4
`

But... there literally isn't a line after line4\n in the patch above. We're not forgiving a blank context line lacking the trailing space; we're imagining an extra blank context line where there was literally no line at all.

I can see the argument for why this makes sense to do given that we already

  1. forgive the absence of a leading space on a blank context line and also
  2. forgive the absence of a trailing newline on the last line of a patch

This PR effectively modifies our handling of the scenario where a patch is malformed in BOTH of these ways at once (i.e. where there should've been a final " \n" at the end of the patch, and both the space AND the newline have been truncated). Arguably this is a consistency improvement, I guess, since it's just allowing the two existing error-forgiveness behaviours to occur simultaneously. But I think adding an entire extra line to a hunk that was not in any sense actually included is a more radical kind of tolerance of malformed patches than jsdiff has done before now and am not sure I'm comfortable with it; it has the effect that by just incrementing the line counts in the final hunk header, you can cause jsdiff to hallucinate an extra context line at the end of the hunk (rather than complaining that your hunk is missing a line, like you might expect it to - admittedly the existing error message is NOT helpful in this scenario).

Am I understanding correctly, do you think, @rtritto? I'd welcome your thoughts.

@rtritto
Copy link
Copy Markdown
Author

rtritto commented May 26, 2026

@ExplodingCabbage thanks for reply, my use case (reproduction) is with applyPatch (from jsdiff) which applies this patch (generated by yarn or pnpm) to @universal-deploy/vite@0.1.9 package:

--- a/dist/index.js
+++ b/dist/index.js
@@ -121,11 +121,16 @@ function auto(options) {
     return [
         catchAll(),
         devServer(),
-		...node$1(options?.node).map((p) => {
-			p[INSTANCE] = instance;
-			return enablePluginIf((config) => noDeploymentTargetFound(p, config), p);
-		}),
-		...netlifyGlue()
+		{
+			name: "ud:target:emit",
+			apply: "build",
+			config: {
+				order: "post",
+				handler() {
+					return { environments: { ssr: { build: { rolldownOptions: { input: { index: './src/server/entrypoint.ts' } } } } } }
+				}
+			}
+		}
     ];
 }

Output:

file:///C:/Users/<USER>/AppData/Local/Yarn/Berry/cache/diff-npm-9.0.0-7def25b473-10c0.zip/node_modules/diff/libesm/patch/parse.js:478
                throw new Error(`Hunk at line ${chunkHeaderIndex + 1} contained invalid line ${diffstr[i]}`);
                      ^

Error: Hunk at line 3 contained invalid line 
    at parseHunk (file:///C:/Users/<USER>/AppData/Local/Yarn/Berry/cache/diff-npm-9.0.0-7def25b473-10c0.zip/node_modules/diff/libesm/patch/parse.js:478:23)
    at parseIndex (file:///C:/Users/<USER>/AppData/Local/Yarn/Berry/cache/diff-npm-9.0.0-7def25b473-10c0.zip/node_modules/diff/libesm/patch/parse.js:190:34)
    at parsePatch (file:///C:/Users/<USER>/AppData/Local/Yarn/Berry/cache/diff-npm-9.0.0-7def25b473-10c0.zip/node_modules/diff/libesm/patch/parse.js:509:9)
    at applyPatch (file:///C:/Users/<USER>/AppData/Local/Yarn/Berry/cache/diff-npm-9.0.0-7def25b473-10c0.zip/node_modules/diff/libesm/patch/apply.js:30:19)
    at applyPatchCore (file:///C:/<MY_PROJECT>/src/applyPatchCore.ts:30:25)
    at file:///C:/<MY_PROJECT>/src/applyPatch.ts:6:11
    at withPatchLifecycle (file:///C:/<MY_PROJECT>/src/utils.ts:39:9)
    at applyPatch (file:///C:/<MY_PROJECT>/src/applyPatch.ts:5:9)
    at file:///C:/<MY_PROJECT>/scripts/patcher.ts:3:7
    at ModuleJob.run (node:internal/modules/esm/module_job:439:25)

Workaround: change @@ -121,11 +121,16 @@ to @@ -121,10 +121,15 @@

PS: tell me if you need some further help

@rtritto
Copy link
Copy Markdown
Author

rtritto commented May 26, 2026

Am I understanding correctly, do you think, @rtritto? I'd welcome your thoughts.

You are spot on – your understanding is exactly correct. The PR does indeed allow jsdiff to effectively "hallucinate" an extra context line at the end of the hunk, basically forgiving a patch that is missing both its trailing space and its trailing newline.

To give you some context on why I proposed this: it addresses a very frustrating and common real-world edge case when using ecosystem tools like yarn patch.
When a patch logically ends with a blank context line (" \n"), developers often open the .patch file in an editor (like VS Code) to make a quick manual tweak. Default editor configurations like "Trim Trailing Whitespace" and "Trim Final Newlines" will quietly strip both the space and potentially the entire final empty line upon saving.

When this slightly truncated patch is fed back into jsdiff, it hits the EOF and throws:
Error: Hunk at line X contained invalid line
Because it's evaluating the final "" string from the .split(/\n/), the error is extremely cryptic (yielding undefined). Developers could waste hours trying to find a syntax error, when in reality the hunk is just missing its final empty line.

Standard GNU patch is famously lenient with truncated contexts at the end of files, and my goal was to mimic that resilience. That said, I completely respect your hesitation. Synthesizing a line purely to satisfy the hunk header's expected line count is a more radical kind of tolerance.

If you feel this level of leniency is too risky or outside the scope of jsdiff, I completely understand. In that case, would you be open to an alternative approach where we simply catch this EOF scenario and throw a much better error message?
Something like Hunk at line X ended prematurely (expected N more lines) instead of trying to evaluate diffstr[i][0]? Even just improving the error message would turn a huge headache into an easily actionable fixable problem for users.

WDYT?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants