Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
2176cd8
Another try at adding Git support, this time with Claude Opus 4.6
ExplodingCabbage Mar 3, 2026
f35ab14
Fix
ExplodingCabbage Mar 3, 2026
9b5d323
Fix
ExplodingCabbage Mar 3, 2026
04df6c0
More tests
ExplodingCabbage Mar 3, 2026
2a367ff
Fix a comment
ExplodingCabbage Mar 3, 2026
d72de9e
refactor
ExplodingCabbage Mar 3, 2026
ae01ddd
Further refactor
ExplodingCabbage Mar 4, 2026
c32112e
Comment tweaks
ExplodingCabbage Mar 4, 2026
b9bd0b8
Fix more comments
ExplodingCabbage Mar 4, 2026
44a9dba
Document an error case better
ExplodingCabbage Mar 4, 2026
ba1d8e8
Docs correction
ExplodingCabbage Mar 4, 2026
b79069e
Latest AI changes
ExplodingCabbage Mar 9, 2026
636963a
Further fixes
ExplodingCabbage Mar 9, 2026
bfaa2e2
Shorten & simplify README prose
ExplodingCabbage Mar 9, 2026
90c1bea
Further bugfix
ExplodingCabbage Mar 9, 2026
1130b96
stuff
ExplodingCabbage Mar 10, 2026
e56667a
fix
ExplodingCabbage Mar 10, 2026
774bfcb
fixes
ExplodingCabbage Mar 10, 2026
4268495
active reading
ExplodingCabbage Mar 10, 2026
ed4e4d6
fix
ExplodingCabbage Mar 10, 2026
0b85103
Simplifying refactor
ExplodingCabbage Mar 10, 2026
cc8f5d6
Improve a comment
ExplodingCabbage Mar 10, 2026
328e76d
Improve comment
ExplodingCabbage Mar 10, 2026
bf199ff
Claude-generated tests
ExplodingCabbage Mar 10, 2026
16b917d
Simplify algo
ExplodingCabbage Mar 10, 2026
c48eb99
Slight simplification
ExplodingCabbage Mar 10, 2026
ffb87bc
latest changes, manual todos
ExplodingCabbage Mar 10, 2026
32ef099
comment fixes
ExplodingCabbage Mar 10, 2026
37cd6c0
Tighten comment
ExplodingCabbage Mar 10, 2026
b3ce9ee
fix
ExplodingCabbage Mar 10, 2026
ed535b0
Quote in formatPatch
ExplodingCabbage Mar 13, 2026
37b0fa9
Missing TODO
ExplodingCabbage Mar 13, 2026
e1bbfa4
another todo
ExplodingCabbage Mar 13, 2026
80bb7d1
fix
ExplodingCabbage Mar 16, 2026
856c9e4
fix
ExplodingCabbage Mar 16, 2026
587de38
Improve create.js tests, mostly by removing redundancy
ExplodingCabbage Mar 16, 2026
6a10cc9
Formatting
ExplodingCabbage Mar 16, 2026
ae7a6c0
Add isBinary
ExplodingCabbage Mar 16, 2026
46d8a6e
simplify
ExplodingCabbage Mar 16, 2026
e59e563
Remove weird unneeded test with unrealistic patch
ExplodingCabbage Mar 16, 2026
1e2881e
Remove another redundant test
ExplodingCabbage Mar 16, 2026
2d01865
Remove implication only emojis are multiple utf-8 bytes
ExplodingCabbage Mar 16, 2026
cf2d528
Merge two tests, for succinctness
ExplodingCabbage Mar 16, 2026
05a589e
Remove more redundant tests
ExplodingCabbage Mar 16, 2026
eab3f7a
More test polishing
ExplodingCabbage Mar 16, 2026
48d7a87
formating
ExplodingCabbage Mar 16, 2026
690ab1c
More fixing
ExplodingCabbage Mar 16, 2026
48cdec2
Remove pointless test
ExplodingCabbage Mar 16, 2026
52d5133
fix indentation
ExplodingCabbage Mar 16, 2026
7e9b5fd
Formatting
ExplodingCabbage Mar 16, 2026
d989001
Update docs
ExplodingCabbage Mar 17, 2026
c38fa8a
simplify
ExplodingCabbage Mar 17, 2026
4082b00
simplify
ExplodingCabbage Mar 17, 2026
82bb141
simplify
ExplodingCabbage Mar 17, 2026
792b331
Fix karma tests
ExplodingCabbage Mar 18, 2026
197aff7
simplify
ExplodingCabbage Mar 18, 2026
de265ee
Fixes
ExplodingCabbage Mar 18, 2026
81c192e
Another fix
ExplodingCabbage Mar 18, 2026
31b0a23
Work on release notes
ExplodingCabbage Mar 18, 2026
7b03df7
Tweak rules for what counts as a valid patch
ExplodingCabbage Mar 22, 2026
1b9ce2e
more notes
ExplodingCabbage Mar 22, 2026
f32ed34
More docs
ExplodingCabbage Mar 22, 2026
dddc92a
notes
ExplodingCabbage Mar 22, 2026
b031706
Merge remote-tracking branch 'origin/master' into git-support-attempt…
ExplodingCabbage Mar 23, 2026
00a801d
Merge remote-tracking branch 'origin/master' into git-support-attempt…
ExplodingCabbage Mar 23, 2026
c3bc6d2
docs tidying
ExplodingCabbage Mar 25, 2026
616bbc9
typo fixes
ExplodingCabbage Mar 25, 2026
f679cab
Saner formatPatch behaviour with isGit patches and headerOptions
ExplodingCabbage Mar 25, 2026
a6f224a
Slight simplification
ExplodingCabbage Mar 25, 2026
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
84 changes: 81 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ jsdiff's diff functions all take an old text and a new text and perform three st

* `formatPatch(patch[, headerOptions])` - creates a unified diff patch.

`patch` may be either a single structured patch object (as returned by `structuredPatch`) or an array of them (as returned by `parsePatch`). The optional `headerOptions` argument behaves the same as the `headerOptions` option of `createTwoFilesPatch`.
`patch` may be either a single structured patch object (as returned by `structuredPatch`) or an array of them (as returned by `parsePatch`). The optional `headerOptions` argument behaves the same as the `headerOptions` option of `createTwoFilesPatch`, except that it is ignored for Git patches (i.e. patches where `isGit` is `true`).

When a patch has `isGit: true`, `formatPatch` output is changed to more closely match Git's output: it emits a `diff --git` header, emits Git extended headers as appropriate based on properties like `isRename`, `isCreate`, `newMode`, etc, and always emits `---`/`+++` file headers when hunks are present but omits them when there are no hunks (e.g. renames without content changes). The `headerOptions` parameter has no effect on Git patches since the header format is fully determined by the Git extended header properties.

* `structuredPatch(oldFileName, newFileName, oldStr, newStr[, oldHeader[, newHeader[, options]]])` - returns an object with an array of hunk objects.

Expand Down Expand Up @@ -184,14 +186,26 @@ jsdiff's diff functions all take an old text and a new text and perform three st

Once all patches have been applied or an error occurs, the `options.complete(err)` callback is made.

* `parsePatch(diffStr)` - Parses a patch into structured data
* `parsePatch(diffStr)` - Parses a unified diff format patch into a structured patch object.

Returns a JSON object representation of the patch, suitable for use with the `applyPatch` method. This parses to the same structure returned by `structuredPatch`, except that `oldFileName` and `newFileName` may be `undefined` if the patch doesn't contain enough information to determine them (e.g. a hunk-only patch with no file headers).

Return a JSON object representation of the patch, suitable for use with the `applyPatch` method. This parses to the same structure returned by `structuredPatch`.
`parsePatch` has some understanding of [Git's particular dialect of unified diff format](https://git-scm.com/docs/git-diff#generate_patch_text_with_p). When parsing a Git patch, each index in the result may contain the following additional fields not included in the data structure returned by `structuredPatch`:
- `isGit` - set to `true` when parsing from a Git-style patch.
- `isRename` - set to `true` when parsing a Git diff that includes `rename from`/`rename to` extended headers, indicating the file was renamed (and the old file no longer exists). Consumers applying the patch should delete the old file.
- `isCopy` - set to `true` when parsing a Git diff that includes `copy from`/`copy to` extended headers, indicating the file was copied (and the old file still exists). Consumers applying the patch should NOT delete the old file.
- `isCreate` - set to `true` when parsing a Git diff that includes a `new file mode` extended header, indicating the file was newly created.
- `isDelete` - set to `true` when parsing a Git diff that includes a `deleted file mode` extended header, indicating the file was deleted.
- `oldMode` - the file mode (e.g. `'100644'`, `'100755'`) of the old file, parsed from Git extended headers (`old mode` or `deleted file mode`).
- `newMode` - the file mode (e.g. `'100644'`, `'100755'`) of the new file, parsed from Git extended headers (`new mode` or `new file mode`).
- `isBinary` - set to `true` when parsing a Git diff that includes a `Binary files ... differ` line, indicating a binary file change. Binary patches have no hunks, so the patch content alone is not sufficient to apply the change; consumers should handle this case specially (e.g. by warning the user or fetching the binary content separately).

* `reversePatch(patch)` - Returns a new structured patch which when applied will undo the original `patch`.

`patch` may be either a single structured patch object (as returned by `structuredPatch`) or an array of them (as returned by `parsePatch`).

When `patch` is a Git-style patch, `reversePatch` handles extended header information (relating to renames, file modes, etc.) to the extent that doing so is possible, but note one fundamental limitation: the correct inverse of a patch featuring `copy from`/`copy to` headers cannot, in general, be determined based on the information contained in the patch alone, and so `reversePatch`'s output when passed such a patch will usually be rejected by `git apply`. (The correct inverse would be a patch that deletes the newly-created file, but for Git to apply such a patch, the patch must explicitly delete every line of content in the file too, and that content cannot be determined from the original patch on its own. `reversePatch` therefore does the only vaguely reasonable thing it can do in this scenario: it outputs a patch with a `deleted file mode` header - indicating that the file should be deleted - but no hunks.)

* `convertChangesToXML(changes)` - converts a list of change objects to a serialized XML format

* `convertChangesToDMP(changes)` - converts a list of change objects to the format returned by Google's [diff-match-patch](https://github.com/google/diff-match-patch) library
Expand Down Expand Up @@ -360,6 +374,70 @@ applyPatches(patch, {
});
```

##### Applying a multi-file Git patch that may include renames and mode changes

[Git patches](https://git-scm.com/docs/git-diff#generate_patch_text_with_p) can include file renames and copies (with or without content changes), which need to be handled in the callbacks you provide to `applyPatches`. `parsePatch` sets `isRename` or `isCopy` on the structured patch object so you can distinguish these cases. Patches can also potentially include file *swaps* (renaming `a → b` and `b → a`), in which case it is incorrect to simply apply each change atomically in sequence. The pattern with the `pendingWrites` Map below handles all of these nuances:

```
const {applyPatches} = require('diff');
const patch = fs.readFileSync("git-diff.patch").toString();
const DELETE = Symbol('delete');
const pendingWrites = new Map(); // filePath → {content, mode} or DELETE sentinel
applyPatches(patch, {
loadFile: (patch, callback) => {
if (patch.isCreate) {
// Newly created file — no old content to load
callback(undefined, '');
return;
}
try {
// Git diffs use a/ and b/ prefixes; strip them to get the real path
const filePath = patch.oldFileName.replace(/^a\//, '');
callback(undefined, fs.readFileSync(filePath).toString());
} catch (e) {
callback(`No such file: ${patch.oldFileName}`);
}
},
patched: (patch, patchedContent, callback) => {
if (patchedContent === false) {
callback(`Failed to apply patch to ${patch.oldFileName}`);
return;
}
const oldPath = patch.oldFileName.replace(/^a\//, '');
const newPath = patch.newFileName.replace(/^b\//, '');
if (patch.isDelete) {
if (!pendingWrites.has(oldPath)) {
pendingWrites.set(oldPath, DELETE);
}
} else {
pendingWrites.set(newPath, {content: patchedContent, mode: patch.newMode});
// For renames, delete the old file (but not for copies,
// where the old file should be kept)
if (patch.isRename && !pendingWrites.has(oldPath)) {
pendingWrites.set(oldPath, DELETE);
}
}
callback();
},
complete: (err) => {
if (err) {
console.log("Failed with error:", err);
return;
}
for (const [filePath, entry] of pendingWrites) {
if (entry === DELETE) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, entry.content);
if (entry.mode) {
fs.chmodSync(filePath, entry.mode.slice(-3));
}
}
}
}
});
```

## Compatibility

jsdiff should support all ES5 environments. If you find one that it doesn't support, please [open an issue](https://github.com/kpdecker/jsdiff/issues).
Expand Down
5 changes: 5 additions & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export default function(config) {
files: [
'test/**/*.js'
],
exclude: [
// The code being tested by this suite heavily involves Node.js
// filesystem operations, so doesn't make sense to run in a browser:
'test/patch/readme-rename-example.js'
],
preprocessors: {
'test/**/*.js': ['webpack', 'sourcemap']
},
Expand Down
38 changes: 38 additions & 0 deletions release-notes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Release Notes

## 9.0.0 (prerelease)

(All changes part of PR [#672](https://github.com/kpdecker/jsdiff/pull/672).)

- **C-style quoted strings in filename headers are now properly supported**.

When the name of either the old or new file in a patch contains "special characters", both GNU `diff` and Git quote the filename in the patch's headers and escape special characters using the same escape sequences that are used in string literals in C, including octal escapes for all non-ASCII characters. Previously, jsdiff had very little support for this; `parsePatch` would remove the quotes, and unescape any escaped backslashes, but would not unescape other escape sequences. `formatPatch`, meanwhile, did not quote or escape special characters at all.

Now, `parsePatch` parses all the possible escape sequences that GNU diff (or Git) ever output, and `formatPatch` quotes and escapes filenames containing special characters in the same way GNU diff does.

- **`formatPatch` now omits file headers when `oldFileName` or `newFileName` in the provided patch object are `undefined`**, regardless of the `headerOptions` parameter. (Previously, it would treat the absence of `oldFileName` or `newFileName` as indicating the filename was the word "undefined" and emit headers `--- undefined` / `+++ undefined`.)

- **`formatPatch` no longer outputs trailing tab characters at the end of `---`/`+++` headers.**

Previously, if `formatPatch` was passed a patch object to serialize that had empty strings for the `oldHeader` or `newHeader` property, it would include a trailing tab character after the filename in the `---` and/or `+++` file header. Now, this scenario is treated the same as when `oldHeader`/`newHeader` is `undefined` - i.e. the trailing tab is omitted.

- **Git-style patches are now supported by `parsePatch`, `formatPatch`, and `reversePatch`**.

Patches output by `git diff` can include some features that are unlike those output by GNU `diff`, and therefore not handled by an ordinary unified diff format parser. An ordinary diff simply describes the differences between the *content* of two files, but Git diffs can also indicate, via "extended headers", the creation or deletion of (potentially empty) files, indicate that a file was renamed, and contain information about file mode changes. Furthermore, when these changes appear in a diff in the absence of a content change (e.g. when an empty file is created, or a file is renamed without content changes), the patch will contain no associated `---`/`+++` file headers nor any hunks.

jsdiff previously did not support parsing Git's extended headers, nor hunkless patches. Now `parsePatch` parses some of the extended headers, parses hunkless Git patches, and can determine filenames (e.g. from the extended headers) when parsing a patch that includes no `---` or `+++` file headers. The additional information conveyed by the extended headers we support is recorded on new fields on the result object returned by `parsePatch`. See `isGit` and subsequent properties in the docs in the README.md file.

`formatPatch` now outputs extended headers based on these new Git-specific properties, and `reversePatch` respects them as far as possible (with one unavoidable caveat noted in the README.md file).

- **Unpaired file headers now cause `parsePatch` to throw**.

It remains acceptable to have a patch with no file headers whatsoever (e.g. one that begins with a `@@` hunk header on the very first line), but a patch with *only* a `---` header or only a `+++` header is now considered an error.

- **`parsePatch` is now more tolerant of "trailing garbage"**

That is: after a patch, or between files/indexes in a patch, it is now acceptable to have arbitrary lines of "garbage" (so long as they unambiguously have no syntactic meaning - e.g. trailing garbage that leads with a `+`, `-`, or ` ` and thus is interpretable as part of a hunk still triggers a throw).

This means we no longer reject patches output by tools that include extra data in "garbage" lines not understood by generic unified diff parsers. (For example, SVN patches can include "Property changes on:" lines that generic unified diff parsers should discard as garbage; jsdiff previously threw errors when encountering them.)

This change brings jsdiff's behaviour more in line with GNU `patch`, which is highly permissive of "garbage".

- **The `oldFileName` and `newFileName` fields of `StructuredPatch` are now typed as `string | undefined` instead of `string`**. This type change reflects the (pre-existing) reality that `parsePatch` can produce patches without filenames (e.g. when parsing a patch that simply contains hunks with no file headers).

## 8.0.4

- [#667](https://github.com/kpdecker/jsdiff/pull/667) - **fix another bug in `diffWords` when used with an `Intl.Segmenter`**. If the text to be diffed included a combining mark after a whitespace character (i.e. roughly speaking, an accented space), `diffWords` would previously crash. Now this case is handled correctly.
Expand Down
Loading