I started using pnpm's patch and patch-commit commands to patch an external library. Then pnpm itself broke while I was saving the patch.
Specify the package version you want to modify and run the patch command:
pnpm patch is-positive@1.0.0
pnpm extracts the package into an editable temporary directory and prints its path:
You can now edit the following folder:
node_modules/.pnpm_patches/is-positive@1.0.0
After editing the files, finalize the difference with pnpm patch-commit:
pnpm patch-commit node_modules/.pnpm_patches/is-positive@1.0.0
pnpm computes the difference between the original package and the edited copy, saves it under patches, and registers it in patchedDependencies.
# pnpm-workspace.yaml
patchedDependencies:
is-positive@1.0.0: patches/is-positive@1.0.0.patch
root
├── patches
│ └── is-positive@1.0.0.patch
├── pnpm-workspace.yaml
└── pnpm-lock.yaml
From then on, pnpm applies the same patch whenever that package version is installed.
One small clarification: patch-commit finalizes an edited directory as a pnpm patch. It does not create a Git commit, so you still need to commit the result yourself.
When I tried to run patch-commit, I got this error:
[ERR_PNPM_INVALID_PATCH]
Bad diff line: diff --git a/index.js "b/tmp/pnpm-patch-\355\225\234\352\270\200/index.js"
At a high level, patch-commit works in this order:
- Read the edited directory.
- Verify that pnpm created it for patching.
- Prepare the original package.
- Generate a diff between the original and edited copies.
- Save
patches/*.patch. - Update
patchedDependencies. - Run install again to apply the patch.
The problem was step 4, inside diffFolders. Here is just the portion needed for this discussion:
async function diffFolders(folderA: string, folderB: string): Promise<string> {
// ...
let stdout!: string
try {
const result = await execa("git", [
"-c", "core.safecrlf=false",
"diff",
"--src-prefix=a/",
"--dst-prefix=b/",
"--ignore-cr-at-eol",
"--irreversible-delete",
"--full-index",
"--no-index",
"--text",
"--no-ext-diff",
"--no-color",
folderAN,
folderBN,
])
stdout = result.stdout as string
} catch (err: any) {
stdout = err.stdout as string
// ...
}
// ...
return stdout
.replace(new RegExp(`(a|b)(${escapeStringRegexp(`/${removeTrailingAndLeadingSlash(folderAN)}/`)})`, 'g'), '$1/')
.replace(new RegExp(`(a|b)${escapeStringRegexp(`/${removeTrailingAndLeadingSlash(folderBN)}/`)}`, 'g'), '$1/')
.replace(new RegExp(escapeStringRegexp(`${folderAN}/`), 'g'), '')
.replace(new RegExp(escapeStringRegexp(`${folderBN}/`), 'g'), '')
.replace(/\n\\ No newline at end of file\n$/, '\n')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]+?(?=^diff --git)/gm, '')
.replace(/^diff --git a\/.*\.DS_Store b\/.*\.DS_Store[\s\S]*$/gm, '')
}
The path known to pnpm was /tmp/pnpm-patch-한글, while Git printed /tmp/pnpm-patch-\355\225\234\352\270\200.
The replacement expressions therefore failed to recognize the directory. The function returned a line like diff --git a/index.js "b/tmp/pnpm-patch-\355\225\234\352\270\200/index.js", which the patch parser read as invalid and reported as ERR_PNPM_INVALID_PATCH.
Git escapes non-ASCII paths by default. The fix was to tell Git not to quote those bytes:
"-c", "core.quotePath=false"
// Set a one-off configuration value
-c
// core.quotePath (default: true)
// true: show bytes greater than 0x80 in octal notation
core.quotePath=true
/tmp/pnpm-patch-\355\225\234\352\270\200
// false: print non-ASCII bytes as-is
core.quotePath=false
/tmp/pnpm-patch-한글
More precisely, core.quotePath=false stops treating bytes above 0x80 as unusual. Quotes, backslashes, and control characters are still escaped.
I was also curious about the existing core.safecrlf=false argument. Text files commonly use either LF (\n, mostly Linux and macOS) or CRLF (\r\n, mostly Windows). On Windows, a conversion like this can happen:
// Original
A\r\n
B\n
// Added to Git, normalized to LF
A\n
B\n
// Checked out as CRLF
A\r\n
B\r\n
The checked-out result no longer matches the original. safecrlf=false does not disable line-ending conversion itself; it disables the check that rejects irreversible conversion. pnpm only needs to calculate a difference here, so there is no reason to stop on that check.
I applied the same configuration to both the TypeScript implementation and its Rust port, added tests, and opened a PR. An AI review pointed out that a path such as -custom-edit-dir could be interpreted as an option, and asked me to insert -- between the options and path arguments.
The PR was ultimately merged!