Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
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
36 changes: 36 additions & 0 deletions release-notes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
# 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)
- Note fixes to #640 and #648

---


- **`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
41 changes: 35 additions & 6 deletions src/patch/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,42 @@ 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 ' + (patch.oldFileName ?? '') + ' ' + (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 ' + (patch.oldFileName ?? '').replace(/^a\//, ''));
ret.push('rename to ' + (patch.newFileName ?? '').replace(/^b\//, ''));
}
if (patch.isCopy) {
ret.push('copy from ' + (patch.oldFileName ?? '').replace(/^a\//, ''));
ret.push('copy to ' + (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) {

// 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('--- ' + patch.oldFileName + (typeof patch.oldHeader === 'undefined' ? '' : '\t' + patch.oldHeader));
ret.push('+++ ' + patch.newFileName + (typeof patch.newHeader === 'undefined' ? '' : '\t' + patch.newHeader));
}
Expand Down
Loading