Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
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
70 changes: 68 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,11 @@ 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.

Return a JSON object representation of the a patch, suitable for use with the `applyPatch` method. This parses to the same structure returned by `structuredPatch`.
Return a JSON object representation of the a 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).

`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). In particular, it can extract filenames from the patch headers or extended headers of Git patches that contain no hunks and no file headers, including ones representing a file being renamed without changes. (However, it ignores many extended headers that describe things irrelevant to jsdiff's patch representation, like mode changes.)

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

Expand Down Expand Up @@ -360,6 +362,70 @@ applyPatches(patch, {
});
```

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

[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, parseInt(entry.mode, 8) & 0o777);
}
}
}
}
});
```

## 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
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)

TODO:
- Tidy up AI slop below the ---
- Note support for parsing quoted filenames in +++ and --- headers (even outside Git patches as `diff -u` outputs these)
- Also in formatPatch
- Note fixes to #640 and #648
- Note fix to formatPatch in case where file name is undefined (prev emitted 'undefined' literally)

---


- **`parsePatch` now robustly handles Git-style diffs.** Previously, `parsePatch` had inconsistent regex usage that caused several bugs when parsing `diff --git` output:
* Multi-file Git diffs containing hunk-less entries (e.g. mode-only changes, binary files, rename-only entries without content changes) could cause file entries to be merged together or lost entirely.
* Git extended headers (`rename from`/`rename to`, `copy from`/`copy to`, `old mode`/`new mode`, `index`, etc.) were not parsed and could cause parse errors.
* `diff --git` was not consistently recognized as a diff header, leading to missing `index` entries and other subtle issues.

The parser now:
* Correctly recognizes `diff --git` headers and parses filenames from them, including C-style quoted filenames (used by Git when paths contain tabs, newlines, backslashes, or double quotes).
* Consumes Git extended headers (`rename from`/`rename to`, `copy from`/`copy to`, mode changes, similarity index, etc.) without choking.
* Handles hunk-less entries (rename-only, mode-only, binary) as distinct file entries rather than merging them into adjacent entries.
* Sets metadata flags on the resulting `StructuredPatch`: `isGit` (always, for Git diffs), `isRename`, `isCopy`, `isCreate`, `isDelete` (when the corresponding extended headers are present), and `oldMode`/`newMode` (parsed from `old mode`, `new mode`, `deleted file mode`, or `new file mode` headers). This lets consumers distinguish renames (where the old file should be deleted) from copies (where it should be kept), detect file creations and deletions, and preserve file mode information.
* Uses consistent, centralized helper functions for header detection instead of duplicated regexes.

- **`reversePatch` now correctly reverses copy patches.** Reversing a copy produces a deletion (the reversed patch has `newFileName` set to `'/dev/null'` and `isCopy`/`isRename` unset), since undoing a copy means deleting the file that was created. Reversing a rename still produces a rename in the opposite direction, as before.

- **`formatPatch` now supports Git-style patches.** When a `StructuredPatch` has `isGit: true`, `formatPatch` emits a `diff --git` header (instead of `Index:` / underline) and the appropriate Git extended headers (`rename from`/`rename to`, `copy from`/`copy to`, `deleted file mode`, `new file mode`, `old mode`/`new mode`) based on the patch's metadata flags. File headers (`---`/`+++`) are omitted on hunk-less Git patches (e.g. pure renames, mode-only changes), matching Git's own output. This means `parsePatch` output can be round-tripped through `formatPatch`.

- **`formatPatch` now gracefully handles patches with undefined filenames** instead of emitting nonsensical headers like `--- undefined`. If `oldFileName` or `newFileName` is `undefined`, the `---`/`+++` file headers and the `Index:` line are silently omitted. This is consistent with how such patches can arise from parsing Git diffs that lack `---`/`+++` lines.

- **README: added documentation and example for applying Git patches that include renames, copies, deletions, and file creations** using `applyPatches`.

### Breaking changes

- **The `oldFileName` and `newFileName` fields of `StructuredPatch` are now typed as `string | undefined` instead of `string`.** This reflects the reality that `parsePatch` can produce patches without filenames (e.g. when parsing a Git diff with an unparseable `diff --git` header and no `---`/`+++` fallback). TypeScript users who access these fields without null checks will see type errors and should update their code to handle the `undefined` case.

- **`StructuredPatch` has new optional fields for Git metadata:** `isGit`, `isRename`, `isCopy`, `isCreate`, `isDelete`, `oldMode`, and `newMode`. These are set by `parsePatch` when parsing Git diffs. Code that does exact deep-equality checks (e.g. `assert.deepEqual`) against `StructuredPatch` objects from `parsePatch` may need updating to account for the new fields.

## 8.0.4 (prerelease)

- [#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
116 changes: 108 additions & 8 deletions src/patch/create.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,77 @@
import {diffLines} from '../diff/line.js';
import type { StructuredPatch, DiffLinesOptionsAbortable, DiffLinesOptionsNonabortable, AbortableDiffOptions, ChangeObject } from '../types.js';

/**
* Returns true if the filename contains characters that require C-style
* quoting (as used by Git and GNU diffutils in diff output).
*/
function needsQuoting(s: string): boolean {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 0x20 || c > 0x7e || s[i] === '"' || s[i] === '\\') {
return true;
}
}
return false;
}

/**
* C-style quotes a filename, encoding special characters as escape sequences
* and non-ASCII bytes as octal escapes. This is the inverse of
* `parseQuotedFileName` in parse.ts.
*
* Non-ASCII bytes are encoded as UTF-8 before being emitted as octal escapes.
* This matches the behaviour of both Git and GNU diffutils, which always emit
* UTF-8 octal escapes regardless of the underlying filesystem encoding (e.g.
* Git for Windows converts from NTFS's UTF-16 to UTF-8 internally).
*
* If the filename doesn't need quoting, returns it as-is.
*/
function quoteFileNameIfNeeded(s: string): string {
if (!needsQuoting(s)) {
return s;
}

let result = '"';
const bytes = new TextEncoder().encode(s);
let i = 0;
while (i < bytes.length) {
const b = bytes[i];

// See https://en.wikipedia.org/wiki/Escape_sequences_in_C#Escape_sequences
if (b === 0x07) {
result += '\\a';
} else if (b === 0x08) {
result += '\\b';
} else if (b === 0x09) {
result += '\\t';
} else if (b === 0x0a) {
result += '\\n';
} else if (b === 0x0b) {
result += '\\v';
} else if (b === 0x0c) {
result += '\\f';
} else if (b === 0x0d) {
result += '\\r';
} else if (b === 0x22) {
result += '\\"';
} else if (b === 0x5c) {
result += '\\\\';
} else if (b >= 0x20 && b <= 0x7e) {
// Just a printable ASCII character that is neither a double quote nor a
// backslash; no need to escape it.
result += String.fromCharCode(b);
} else {
// Either part of a non-ASCII character or a control character without a
// special escape sequence; needs escaping as as 3-digit octal escape
result += '\\' + b.toString(8).padStart(3, '0');
}
i++;
}
result += '"';
return result;
}

type StructuredPatchCallbackAbortable = (patch: StructuredPatch | undefined) => void;
type StructuredPatchCallbackNonabortable = (patch: StructuredPatch) => void;

Expand Down Expand Up @@ -292,15 +363,44 @@ export function formatPatch(patch: StructuredPatch | StructuredPatch[], headerOp
}

const ret = [];
if (headerOptions.includeIndex && patch.oldFileName == patch.newFileName) {
ret.push('Index: ' + patch.oldFileName);
}
if (headerOptions.includeUnderline) {
ret.push('===================================================================');

if (patch.isGit) {
// Emit Git-style diff --git header and extended headers
ret.push('diff --git ' + quoteFileNameIfNeeded(patch.oldFileName ?? '') + ' ' + quoteFileNameIfNeeded(patch.newFileName ?? ''));
if (patch.isDelete) {
ret.push('deleted file mode ' + (patch.oldMode ?? '100644'));
}
if (patch.isCreate) {
ret.push('new file mode ' + (patch.newMode ?? '100644'));
}
if (patch.oldMode && patch.newMode && !patch.isDelete && !patch.isCreate) {
ret.push('old mode ' + patch.oldMode);
ret.push('new mode ' + patch.newMode);
}
if (patch.isRename) {
ret.push('rename from ' + quoteFileNameIfNeeded((patch.oldFileName ?? '').replace(/^a\//, '')));
ret.push('rename to ' + quoteFileNameIfNeeded((patch.newFileName ?? '').replace(/^b\//, '')));
}
if (patch.isCopy) {
ret.push('copy from ' + quoteFileNameIfNeeded((patch.oldFileName ?? '').replace(/^a\//, '')));
ret.push('copy to ' + quoteFileNameIfNeeded((patch.newFileName ?? '').replace(/^b\//, '')));
}
} else {
if (headerOptions.includeIndex && patch.oldFileName == patch.newFileName && patch.oldFileName !== undefined) {
ret.push('Index: ' + patch.oldFileName);
}
if (headerOptions.includeUnderline) {
ret.push('===================================================================');
}
}
if (headerOptions.includeFileHeaders) {
ret.push('--- ' + patch.oldFileName + (typeof patch.oldHeader === 'undefined' ? '' : '\t' + patch.oldHeader));
ret.push('+++ ' + patch.newFileName + (typeof patch.newHeader === 'undefined' ? '' : '\t' + patch.newHeader));

// Emit --- / +++ file headers. For Git patches with no hunks (e.g.
// pure renames, mode-only changes), Git omits these, so we do too.
const hasHunks = patch.hunks.length > 0;
if (headerOptions.includeFileHeaders && patch.oldFileName !== undefined && patch.newFileName !== undefined
&& (!patch.isGit || hasHunks)) {
ret.push('--- ' + quoteFileNameIfNeeded(patch.oldFileName) + (typeof patch.oldHeader === 'undefined' ? '' : '\t' + patch.oldHeader));
ret.push('+++ ' + quoteFileNameIfNeeded(patch.newFileName) + (typeof patch.newHeader === 'undefined' ? '' : '\t' + patch.newHeader));
}

for (let i = 0; i < patch.hunks.length; i++) {
Expand Down
Loading