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:

  1. Read the edited directory.
  2. Verify that pnpm created it for patching.
  3. Prepare the original package.
  4. Generate a diff between the original and edited copies.
  5. Save patches/*.patch.
  6. Update patchedDependencies.
  7. 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 (opens in a new window). 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.

This was essentially the entire fix

The PR was ultimately merged!

I even got a congratulations message, haha