NFD và NFC trong Git: sửa lỗi tên file tiếng Việt trên Mac
Nếu chạy git status trên Mac mà thấy một file như Hiệp.txt được báo là deleted và ngay bên dưới lại có một file untracked mang đúng cái tên đó, bạn đang gặp xung đột tên file NFD và NFC. Kho Git lưu tên file ở dạng Unicode tổ hợp sẵn (NFC, chữ ệ là một code point duy nhất), còn macOS lại đưa cho Git dạng tách rời (NFD, chữ e kèm theo các dấu ghép riêng). Git so sánh từng byte, nên với Git đây là hai file khác nhau. Cách sửa phía Mac chỉ là một dòng cấu hình, git config core.precomposeunicode true; nếu tên dạng NFD đã lỡ được commit, dùng git mv để đổi về dạng NFC. Phần còn lại của bài hướng dẫn cách xác nhận chẩn đoán và cách chặn NFD khỏi script, máy chủ và form upload của bạn.
Vì sao Git thấy hai file Hiệp.txt: tên file NFD và NFC
Unicode cho phép một số ký tự được viết theo hai cách. Chữ ệ có thể là một code point duy nhất (U+1EC7, dạng "tổ hợp sẵn") hoặc ba code point: e (U+0065), dấu nặng ghép (U+0323) rồi dấu mũ ghép (U+0302). Cách đầu là dạng NFC, cách sau là NFD. Cả hai hiển thị y hệt nhau trên màn hình. Tiếng Việt bị ảnh hưởng nặng hơn nhiều ngôn ngữ khác vì rất nhiều chữ mang cùng lúc hai dấu (ệ, ở, ấ, ừ, ộ...), nghĩa là một tên file có thể dài thêm mấy byte so với bản gốc mà bạn không nhìn ra. Chữ đ thì lại là ngoại lệ: nó không tách được, nên Đồng Nai chỉ bị lỗi ở chữ ồ. Vấn đề tương tự xảy ra với tiếng Hàn (한글 có thể tách thành từng jamo) và kana tiếng Nhật có dakuten (が thành か cộng ゛).
Windows, Linux, đa số web server và đa số trình soạn thảo tạo ra NFC. macOS thì từ xưa đã tạo ra NFD: hệ thống file HFS+ cũ chuẩn hóa mọi tên về dạng tách rời, và ngay cả trên APFS, nhiều API của macOS cùng Finder vẫn trả về tên dạng tách rời. Git hoàn toàn không chuẩn hóa đường dẫn. Nó lưu đúng chuỗi byte mà working tree đưa cho và so sánh chuỗi byte đó với index.
Kịch bản thường gặp:
- Đồng nghiệp dùng Linux hoặc Windows commit
docs/Hiệp.txtở dạng NFC. - Bạn clone về Mac. Git ghi file với tên NFC, nhưng hệ thống file hoặc công cụ trên Mac báo lại tên đó ở dạng NFD.
- Git so
Hi\xE1\xBB\x87p.txt(index) vớiHie\xCC\xA3\xCC\x82p.txt(working tree). Không khớp, nên nó báo file NFC bị xóa và file NFD là untracked.
Chiều ngược lại cũng xảy ra: một người dùng Mac commit tên dạng NFD, rồi mọi người trên Linux thấy xuất hiện một file "thứ hai" mà xóa qua giao diện thì file gốc cũng đi theo, hoặc bản build không tìm thấy Hiệp.txt vì đường dẫn trong mã nguồn đang ở dạng NFC.
Kiểm tra xem bạn thực sự đang có dạng nào
Đừng đoán. Hãy nhìn vào byte.
Trên working tree (macOS hoặc Linux):
ls docs | grep -i hi | xxd
- Thấy
e1 bb 87trong kết quả nghĩa là NFC (ệlà một code point). - Thấy
65 cc a3 cc 82nghĩa là NFD (erồi đến U+0323 và U+0302).
Với những chữ chỉ có một dấu thì ngắn hơn: á ở dạng NFC là c3 a1, ở dạng NFD là 61 cc 81.
Trong index, tức là những gì Git đã commit:
git ls-files -z docs | xxd
So sánh hai kết quả. Nếu index hiện e1 bb 87 còn danh sách thư mục hiện 65 cc a3 cc 82, kho Git vẫn ổn và chính máy Mac của bạn đang tách dấu ra. Nếu bản thân index chứa các chuỗi cc a3, cc 82, cc 81... thì tên dạng NFD đã được commit và cần sửa lại.
Cách kiểm tra nhanh toàn bộ kho để tìm tên NFD đã commit:
git ls-files -z | perl -0ne 'print "$_\n" if /[\x{300}-\x{36f}\x{3099}\x{309a}\x{1100}-\x{11ff}]/'
Lệnh này in ra những đường dẫn chứa dấu ghép Latin (toàn bộ dấu tiếng Việt nằm trong khoảng này), dakuten/handakuten ghép của tiếng Nhật, hoặc jamo ghép của tiếng Hàn.
Sửa trong Git
Bước 1: bảo Git trên macOS ghép dấu lại
git config --global core.precomposeunicode true
Khi bật tùy chọn này, Git chuyển tên NFD đọc được từ hệ thống file về NFC trước khi so với index và trước khi commit. Các phiên bản Git mới thường tự dò hệ thống file lúc git init hoặc git clone rồi tự đặt giá trị này trong cấu hình của kho, vì thế vấn đề có vẻ lúc có lúc không tùy máy. Đặt ở mức global để khỏi phụ thuộc may rủi. Đổi xong, chạy lại git status; cặp deleted/untracked ảo sẽ biến mất.
Tùy chọn này chỉ tồn tại trong bản Git dành cho macOS. Trên Linux và Windows nó không làm gì cả, và như vậy là đúng vì hai hệ đó không tách dấu tên file.
Bước 2: sửa những tên đã lỡ commit ở dạng NFD
Nếu index chứa đường dẫn dạng tách rời, hãy đổi chúng sang NFC. Tên phải thay đổi ở mức byte, nên cần đổi qua hai bước (Git từ chối đổi tên sang một tên mà nó coi là trùng trên hệ thống file không phân biệt hoa thường hoặc không phân biệt cách chuẩn hóa):
# Tạo phiên bản NFC của đường dẫn
nfc=$(printf '%s' 'docs/Hiệp.txt' | iconv -f utf-8-mac -t utf-8)
git mv 'docs/Hiệp.txt' docs/tmp-rename
git mv docs/tmp-rename "$nfc"
git commit -m "Chuẩn hóa tên file về NFC"
Thay đường dẫn trong lệnh bằng đường dẫn dạng tách rời lấy từ kết quả git ls-files (copy từ terminal thường giữ nguyên được chuỗi byte NFD). utf-8-mac là tên bảng mã mà iconv trên macOS hiểu; trên Linux dùng Python (phần tiếp theo) hoặc công cụ convmv: convmv -f utf-8 -t utf-8 --nfc --notest docs/Hiệp.txt.
Nếu có nhiều file, hãy lặp qua kết quả của lệnh dò ở trên thay vì đổi tên bằng tay.
Bước 3: tránh bất ngờ khi merge
Khi tên đã thống nhất, file .gitattributes không giúp được gì ở đây (nó xử lý kết thúc dòng và diff, không xử lý chuẩn hóa đường dẫn), nên lớp bảo vệ là tùy chọn cấu hình cộng với thói quen: ai dùng Mac đều phải bật core.precomposeunicode. Một pre-commit hook chạy lệnh dò một dòng ở trên và báo lỗi khi có kết quả khớp là lưới an toàn rẻ tiền cho những nhóm dùng lẫn nhiều hệ điều hành.
Chuẩn hóa trong script, máy chủ và form upload
Git chỉ là nơi vấn đề lộ ra. Cùng những tên NFD đó sẽ làm hỏng script deploy, bản build site tĩnh và bộ xử lý upload vốn tra đường dẫn bằng cách so chuỗi. Nguyên tắc rất đơn giản: chuẩn hóa về NFC ngay tại ranh giới mà một tên file đi vào hệ thống của bạn.
Python:
import unicodedata, os
for name in os.listdir("uploads"):
nfc = unicodedata.normalize("NFC", name)
if nfc != name:
os.rename(os.path.join("uploads", name), os.path.join("uploads", nfc))
Node.js (ví dụ trong bộ xử lý upload, trước khi ghi ra đĩa hoặc đẩy lên object storage):
const safeName = file.originalname.normalize("NFC");
Shell, khi đồng bộ từ Mac lên máy chủ Linux, hãy để rsync tự chuyển đổi để máy chủ không bao giờ thấy NFD:
rsync -av --iconv=utf-8-mac,utf-8 ./site/ user@server:/var/www/site/
Sửa hàng loạt một thư mục đã nằm sẵn trên máy Linux:
convmv -r -f utf-8 -t utf-8 --nfc --notest /var/www/uploads
Hai điều đáng biết. Thứ nhất, object storage như S3 coi key là chuỗi byte thuần, nên Hợp đồng.pdf upload từ Mac và Hợp đồng.pdf tham chiếu trong cơ sở dữ liệu có thể âm thầm là hai key khác nhau; hãy chuẩn hóa trước khi sinh key. Thứ hai, trình duyệt không chuẩn hóa tên file trong multipart upload, nên form trên site của bạn nhận đúng cái mà máy Mac của người dùng tạo ra. Chuẩn hóa phía máy chủ là nơi duy nhất bắt được nó một cách đáng tin cậy.
Khi file đến từ người ngoài quy trình của bạn
Đoạn mã ở trên lo được pipeline của chính bạn. Nó không giúp gì khi một bạn designer dùng Mac thả 300 ảnh tên có dấu như Banner Tết.png, Poster Khuyến mãi.jpg vào ticket, hay một đồng nghiệp người Hàn, người Nhật gửi file ZIP mà tên bên trong mở trên Windows hiện thành từng chữ cái rời (ㅎㅏㄴㄱㅡㄹ thay vì 한글). Những file đó phải được đổi tên trước khi đưa vào kho Git, CMS hay máy chủ, và nhờ người gửi "lưu lại ở dạng NFC giúp" gần như không có tác dụng vì họ không thể nhìn thấy sự khác biệt.
Cho trường hợp đó, ứng dụng web FilenameFixer miễn phí làm việc chuyển đổi mà không cần viết script: kéo file vào trình duyệt, nó đổi tên hàng loạt từ NFD sang NFC; hoặc đưa cho nó file ZIP (tối đa 200 MB) qua công cụ khôi phục tên file trong ZIP để sửa tên bên trong. Nó cũng chuyển đổi văn bản dán vào, tiện khi cần sửa một danh sách đường dẫn trong file cấu hình. Việc xử lý diễn ra ngay trong trình duyệt, file không được tải lên máy chủ nào, điều này quan trọng khi tài liệu là của khách hàng. Nếu nhóm của bạn upload qua form web, tiện ích mở rộng Chrome của FilenameFixer tự sửa tên file ngay lúc upload, lấp đúng lỗ hổng mà trình duyệt để lọt khi chuyển nguyên NFD lên máy chủ.
Còn với bất cứ thứ gì đã nằm trong Git, hãy dùng core.precomposeunicode và git mv. Đổi tên file trong working tree bằng công cụ bên ngoài mà không báo cho Git chỉ tạo lại đúng cặp deleted/untracked mà bạn gặp lúc đầu.