← All articles

NFD vs NFC filenames in Git: fix phantom changes on Mac

If git status on your Mac shows a file like José.txt as deleted and, right underneath, an untracked file with exactly the same name, you are looking at an NFD vs NFC filename clash. The repository stores the name in composed Unicode (NFC, é as one code point) and macOS hands Git the decomposed form (NFD, e plus a combining accent). Git compares bytes, so to Git these are two different files. The fix on the Mac side is one setting, git config core.precomposeunicode true, and if decomposed names have already been committed, a git mv to the composed form. The rest of this article shows how to confirm the diagnosis and how to keep NFD out of your scripts, servers and uploads.

Why Git sees two José.txt files: NFD vs NFC filenames

Unicode allows some characters to be written two ways. é can be a single code point (U+00E9, "precomposed") or two code points: e (U+0065) followed by a combining acute accent (U+0301). The first is the NFC form, the second is NFD. Both render identically on screen. The same applies to ë in Zoë, ñ, ü, and far more visibly to Korean (한글 can decompose into separate jamo) and Japanese kana with dakuten (が as か plus ゛).

Windows, Linux, most web servers and most editors produce NFC. macOS has historically produced NFD: the old HFS+ file system normalized every name to decomposed form, and even on APFS many macOS APIs and Finder still hand you decomposed names. Git does not normalize paths at all. It stores whatever bytes the working tree gives it and compares those bytes with the index.

So the typical sequence is:

  1. A colleague on Linux or Windows commits docs/José.txt in NFC.
  2. You clone on a Mac. Git writes the file with the NFC name, but the file system or the Mac tooling reports it back as NFD.
  3. Git compares Jos\xC3\xA9.txt (index) with Jose\xCC\x81.txt (working tree). They do not match, so it reports the NFC file as deleted and the NFD one as untracked.

The reverse happens too: a Mac user commits NFD names, and everyone on Linux sees a "second" file appear that they cannot delete through the GUI without the original following it, or a build that cannot find José.txt because the path in the source code is NFC.

Check which form you actually have

Do not guess. Look at the bytes.

On the working tree (macOS or Linux):

ls docs | grep -i jose | xxd
  • c3 a9 in the output means NFC (é as one code point).
  • 65 cc 81 means NFD (e followed by U+0301).

In the index, which is what Git has committed:

git ls-files -z docs | xxd

Compare the two. If the index shows c3 a9 and the directory listing shows 65 cc 81, the repository is fine and your Mac is the one decomposing. If the index itself contains cc 81 sequences, decomposed names have already been committed and need repairing.

A quicker check across the whole repo for already-committed NFD names:

git ls-files -z | perl -0ne 'print "$_\n" if /[\x{300}-\x{36f}\x{3099}\x{309a}\x{1100}-\x{11ff}]/'

That prints paths containing Latin combining marks, Japanese combining dakuten/handakuten, or Korean conjoining jamo.

Fix it in Git

Step 1: tell Git on macOS to recompose names

git config --global core.precomposeunicode true

With this on, Git converts the NFD names it reads from the file system back to NFC before comparing them with the index and before committing. Newer Git versions usually probe the file system during git init or git clone and set this automatically in the repository config, which is why the problem seems to come and go between machines. Setting it globally removes the lottery. After changing it, run git status again; the phantom deleted/untracked pair should disappear.

This setting only exists in Git's macOS build. On Linux and Windows it does nothing, which is fine because those systems do not decompose names.

Step 2: repair names already committed in NFD

If the index contains decomposed paths, change them to NFC. The name must change byte-for-byte, so do it in two moves (Git refuses a rename to a name it considers identical on a case-insensitive or normalization-insensitive file system):

# Build the NFC version of the path
nfc=$(printf '%s' 'docs/José.txt' | iconv -f utf-8-mac -t utf-8)

git mv 'docs/José.txt' docs/tmp-rename
git mv docs/tmp-rename "$nfc"
git commit -m "Normalize filename to NFC"

Replace the literal path with the decomposed one from your git ls-files output (copying it from the terminal usually preserves the decomposed bytes). utf-8-mac is an encoding name that the macOS iconv understands; on Linux use Python instead (next section) or the convmv tool: convmv -f utf-8 -t utf-8 --nfc --notest docs/José.txt.

If there are many files, loop over the output of the detection command above rather than renaming by hand.

Step 3: avoid merge surprises

Once names are consistent, a .gitattributes file will not help here (it covers line endings and diffs, not path normalization), so the protection is the config setting plus a habit: anyone on a Mac should have core.precomposeunicode on. A pre-commit hook that runs the detection one-liner and fails on any match is a cheap safety net for teams with mixed operating systems.

Normalize in scripts, servers and file uploads

Git is only where the problem becomes visible. The same NFD names break deploy scripts, static-site builds and upload handlers that look paths up by string comparison. The rule is simple: normalize to NFC at the boundary where a name enters your system.

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 (for example in an upload handler before writing to disk or to object storage):

const safeName = file.originalname.normalize("NFC");

Shell, when syncing from a Mac to a Linux server, let rsync do the conversion so the server never sees NFD:

rsync -av --iconv=utf-8-mac,utf-8 ./site/ user@server:/var/www/site/

Bulk-fix a directory already on a Linux box:

convmv -r -f utf-8 -t utf-8 --nfc --notest /var/www/uploads

Two things worth knowing. First, object storage such as S3 treats keys as opaque bytes, so José.jpg uploaded from a Mac and José.jpg referenced from your database may silently be two different keys; normalize before you generate the key. Second, browsers do not normalize the filename in a multipart upload, so a form on your site receives whatever the user's Mac produced. Server-side normalization is the only reliable place to catch it.

When the files come from someone outside your toolchain

The code above covers your own pipeline. It does not help when a designer on a Mac drops 300 images with accented names into a ticket, or a Korean or Japanese colleague sends a ZIP whose names show up on Windows as separated letters (ㅎㅏㄴㄱㅡㄹ instead of 한글). Those files have to be renamed before they go anywhere near a repository, a CMS or a server, and asking the sender to "please save in NFC" rarely works because they cannot see the difference.

For that case the free FilenameFixer web app does the conversion without any scripting: drag the files into the browser and it batch-renames them from NFD to NFC, or hand it the ZIP archive (up to 200 MB) and it restores the names inside. It also converts pasted text, which is handy for fixing a list of paths in a config file. The processing happens locally in the browser, so the files themselves are not uploaded to a server, which matters when the assets are client material. If your team uploads through web forms, the Chrome extension fixes the filenames automatically at upload time, which closes the gap left by browsers that pass NFD through untouched.

For anything already inside Git, stick with core.precomposeunicode and git mv. Renaming files in the working tree with an external tool without telling Git just creates the same deleted/untracked pair you started with.