외부 라이브러리를 패치해보려고 pnpm의 patch, patch-commit을 사용하게 되었고, 패치를 저장하는 단계에서 pnpm이 깨졌다.

수정하려는 버전을 지정하고 patch 명령어를 사용하면

pnpm patch is-positive@1.0.0

pnpm이 패키지를 수정 가능한 임시 디렉터리에 풀고 경로를 알려준다

You can now edit the following folder:
node_modules/.pnpm_patches/is-positive@1.0.0

파일을 수정한 뒤에 pnpm patch-commit으로 차이를 확정한다

pnpm patch-commit node_modules/.pnpm_patches/is-positive@1.0.0

그럼 pnpm에서 원본 패키지와 수정본의 차이를 계산해서 patches에 등록하고 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

그럼 이후부터는 해당 버전을 설치할 때 같은 패치를 적용하게 된다.

아, 그리고 patch-commit은 수정 디렉토리를 pnpm 패치로 확정할 뿐 실제 git commit을 해주는건 아니기 때문에 commit은 따로 해줘야한다.

patch-commit을 하려고 보니 다음과 같은 오류가 발생했다.

[ERR_PNPM_INVALID_PATCH]
Bad diff line: diff --git a/index.js "b/tmp/pnpm-patch-\355\225\234\352\270\200/index.js"

일단 patch-commit이 동작하는 순서는 다음과 같다.

  1. 수정 디렉토리 읽기
  2. pnpm patch가 만든 디렉토리인지 검증
  3. 원본 패키지 준비
  4. 원본과 수정본의 diff 생성
  5. patches/*.patch 저장
  6. patchedDependencies 갱신
  7. install을 다시 실행해 패치 적용

여기서 문제가 되는 부분은 4번 diff를 하는 diffFolders 부분이었는데 논의에 필요한 부분만 가져오면 다음과 같다.

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, '')
}

pnpm이 알고 있는 값: /tmp/pnpm-patch-한글

git이 출력한 값: /tmp/pnpm-patch-\355\225\234\352\270\200

그래서 replace 정규식에서 제대로 걸리지 않은채로 diff --git a/index.js "b/tmp/pnpm-patch-\355\225\234\352\270\200/index.js" 이런식으로 반환되게 되고 해당 내용이 패치를 적용하는 파서에 읽히면서 ERR_PNPM_INVALID_PATCH 오류가 발생하는 것.

git은 기본적으로 한글 같은 비ASCII 경로를 이스케이프해서 출력하기 때문에 git이 비 ASCII 경로를 이스케이프하지 않도록 하는것이 해결방법이었다.

"-c", "core.quotePath=false"
// 일회성 옵션 지정
-c

// core.quotePath (default: true)

// true: 0x80보다 큰 바이트를 8진수 형태로 표시
core.quotePath=true
/tmp/pnpm-patch-\355\225\234\352\270\200

// false: 비ASCII 바이트를 그대로 출력
core.quotePath=false
/tmp/pnpm-patch-한글

정확히는 core.quotePath=false가 0x80보다 큰 바이트만 “특이한 문자” 취급에서 제외하며, 따옴표·백슬래시·제어 문자는 여전히 이스케이프된다.

한 가지 더, 기존 명령에 있는 core.safecrlf=false도 무슨 옵션인지 궁금했다.

If true, makes Git check if converting CRLF is reversible when end-of-line conversion is active.

텍스트 파일에서 줄바꿈을 표현하는 방식은 주로 2가지로 LF (\n, 주로 Linux, macOS), CRLF (\r\n, 주로 Windows)가 있다. 예를 들어서 windows(CRLF)에서 아래와 같은 변환이 가능한데,

// 원본
A\r\n
B\n

// git에 삽입 (LF로 바뀜)
A\n
B\n

// checkout (CRLF로 돌아옴)
A\r\n
B\r\n

이렇게 되면 원본과 달라지게 된다. 그리고 safecrlf=false는 줄바꿈 변환 자체를 끄는 것이 아니라 원본으로 되돌릴 수 없는 비가역 변환 검사를 끄는 값이다. 어차피 pnpm에서는 차이만 계산하기 때문에 이것때문에 중단될 필요가 없기 때문에 false로 해버리는것.

TypeScript 구현뿐 아니라 Rust 포트에도 같은 설정을 반영했고, 몇가지 테스트도 추가하고 PR (새 창에서 열림)을 올렸다. PR 올렸더니 AI 리뷰로 -custom-edit-dir 이런 경로가 오면 옵션으로 해석될 여지가 있다고 옵션과 경로 인자 사이에 -- 를 넣어달라고 했다.

사실상 핵심 수정은 이게 전부다.

결국 머지까지 완료!

푸핫 축하도 받았다