NFD y NFC en Git: arregla los cambios fantasma en Mac
Si en tu Mac git status muestra un archivo como Peña.txt marcado como eliminado (borrado) y, justo debajo, un archivo sin seguimiento con exactamente el mismo nombre, tienes un choque entre NFD y NFC en los nombres de archivo. El repositorio guarda el nombre en Unicode compuesto (NFC, la ñ como un solo punto de código) y macOS le entrega a Git la forma descompuesta (NFD, una n seguida de una virgulilla combinante). Git compara bytes, así que para él son dos archivos distintos. La solución en el Mac es un ajuste, git config core.precomposeunicode true, y si ya se han confirmado nombres descompuestos, un git mv a la forma compuesta. El resto del artículo explica cómo confirmar el diagnóstico y cómo mantener el NFD fuera de tus scripts, servidores y subidas de archivos.
Por qué Git ve dos Peña.txt: nombres de archivo en NFD y NFC
Unicode permite escribir algunos caracteres de dos maneras. La ñ puede ser un único punto de código (U+00F1, "precompuesto") o dos: n (U+006E) seguida de una virgulilla combinante (U+0303). La primera es la forma NFC; la segunda, NFD. En pantalla se ven idénticas. Lo mismo ocurre con la á de García, la é de José, la í de Martínez, la ü de Agüero, y de forma mucho más visible con el coreano (한글 puede descomponerse en letras sueltas, ㅎㅏㄴㄱㅡㄹ) y con los kana japoneses con dakuten (が como か más ゛).
Windows, Linux, la mayoría de servidores web y la mayoría de editores producen NFC. macOS ha producido históricamente NFD: el antiguo sistema de archivos HFS+ normalizaba todos los nombres a la forma descompuesta, e incluso en APFS muchas API de macOS y el propio Finder siguen devolviendo nombres descompuestos. Git no normaliza rutas en absoluto. Guarda los bytes que le da el árbol de trabajo y los compara con los del índice.
La secuencia habitual es esta:
- Un compañero en Linux o Windows confirma
docs/Peña.txten NFC. - Tú clonas en un Mac. Git escribe el archivo con el nombre en NFC, pero el sistema de archivos o las herramientas del Mac se lo devuelven en NFD.
- Git compara
Pe\xC3\xB1a.txt(índice) conPen\xCC\x83a.txt(árbol de trabajo). No coinciden, así que informa del archivo NFC como eliminado y del NFD como sin seguimiento.
También pasa al revés: alguien con Mac confirma nombres en NFD y todo el equipo en Linux ve aparecer un "segundo" archivo que no puede borrar desde la interfaz gráfica sin que el original desaparezca con él, o una compilación que no encuentra Peña.txt porque la ruta escrita en el código fuente está en NFC.
Comprueba qué forma tienes realmente
No adivines. Mira los bytes.
En el árbol de trabajo (macOS o Linux):
ls docs | grep -i pe | xxd
c3 b1en la salida significa NFC (lañcomo un solo punto de código).6e cc 83significa NFD (nseguida de U+0303).
Para una á o una é, busca c3 a1 / c3 a9 (NFC) frente a 61 cc 81 / 65 cc 81 (NFD).
En el índice, es decir, lo que Git tiene confirmado:
git ls-files -z docs | xxd
Compara ambos resultados. Si el índice muestra c3 b1 y el listado del directorio muestra 6e cc 83, el repositorio está bien y es tu Mac el que descompone. Si el propio índice contiene secuencias cc 83 o cc 81, ya se han confirmado nombres descompuestos y hay que repararlos.
Una comprobación rápida de todo el repositorio en busca de nombres NFD ya confirmados:
git ls-files -z | perl -0ne 'print "$_\n" if /[\x{300}-\x{36f}\x{3099}\x{309a}\x{1100}-\x{11ff}]/'
Imprime las rutas que contienen marcas combinantes latinas (acentos, virgulilla, diéresis), dakuten/handakuten combinantes japoneses o jamo coreanos conjuntivos.
Corrígelo en Git
Paso 1: indica a Git en macOS que recomponga los nombres
git config --global core.precomposeunicode true
Con esta opción activada, Git convierte a NFC los nombres NFD que lee del sistema de archivos antes de compararlos con el índice y antes de confirmar. Las versiones recientes de Git suelen sondear el sistema de archivos durante git init o git clone y activar la opción automáticamente en la configuración del repositorio, por eso el problema parece aparecer y desaparecer según la máquina. Activarla de forma global elimina la lotería. Después de cambiarla, ejecuta git status otra vez; la pareja fantasma eliminado/sin seguimiento debería desaparecer.
Este ajuste solo existe en la compilación de Git para macOS. En Linux y Windows no hace nada, lo cual es correcto porque esos sistemas no descomponen los nombres.
Paso 2: repara los nombres ya confirmados en NFD
Si el índice contiene rutas descompuestas, cámbialas a NFC. El nombre tiene que cambiar byte a byte, así que hazlo en dos movimientos (Git se niega a renombrar a un nombre que considera idéntico en un sistema de archivos que no distingue mayúsculas o normalización):
# Construye la versión NFC de la ruta
nfc=$(printf '%s' 'docs/Peña.txt' | iconv -f utf-8-mac -t utf-8)
git mv 'docs/Peña.txt' docs/tmp-rename
git mv docs/tmp-rename "$nfc"
git commit -m "Normalizar nombre de archivo a NFC"
Sustituye la ruta literal por la descompuesta que te dio git ls-files (copiarla desde el terminal suele conservar los bytes descompuestos). utf-8-mac es un nombre de codificación que entiende el iconv de macOS; en Linux usa Python (siguiente sección) o la herramienta convmv: convmv -f utf-8 -t utf-8 --nfc --notest docs/Peña.txt.
Si hay muchos archivos, recorre en un bucle la salida del comando de detección anterior en lugar de renombrar a mano.
Paso 3: evita sorpresas en los merges
Una vez que los nombres son coherentes, un archivo .gitattributes no te servirá aquí (cubre finales de línea y diffs, no la normalización de rutas), así que la protección es el ajuste de configuración más un hábito: cualquiera que trabaje en Mac debe tener core.precomposeunicode activado. Un hook de pre-commit que ejecute la línea de detección y falle si encuentra alguna coincidencia es una red de seguridad barata para equipos con sistemas operativos mezclados.
Normaliza en scripts, servidores y subidas de archivos
Git es solo el lugar donde el problema se hace visible. Los mismos nombres NFD rompen scripts de despliegue, compilaciones de sitios estáticos y gestores de subida que buscan rutas comparando cadenas. La regla es sencilla: normaliza a NFC en la frontera por la que un nombre entra en tu sistema.
Python:
import unicodedata, os
for name in os.listdir("subidas"):
nfc = unicodedata.normalize("NFC", name)
if nfc != name:
os.rename(os.path.join("subidas", name), os.path.join("subidas", nfc))
Node.js (por ejemplo, en un gestor de subidas antes de escribir en disco o en un almacenamiento de objetos):
const nombreSeguro = file.originalname.normalize("NFC");
Shell, al sincronizar desde un Mac a un servidor Linux: deja que rsync haga la conversión para que el servidor nunca vea NFD:
rsync -av --iconv=utf-8-mac,utf-8 ./sitio/ usuario@servidor:/var/www/sitio/
Corrección masiva de un directorio que ya está en una máquina Linux:
convmv -r -f utf-8 -t utf-8 --nfc --notest /var/www/subidas
Dos cosas que conviene saber. Primera: el almacenamiento de objetos como S3 trata las claves como bytes opacos, así que Peña.jpg subido desde un Mac y Peña.jpg referenciado desde tu base de datos pueden ser, sin que nadie lo note, dos claves distintas; normaliza antes de generar la clave. Segunda: los navegadores no normalizan el nombre de archivo en una subida multipart, por lo que un formulario de tu web recibe lo que haya producido el Mac del usuario. La normalización en el servidor es el único punto fiable para atraparlo.
Cuando los archivos vienen de alguien ajeno a tu flujo de trabajo
El código anterior cubre tu propia canalización. No ayuda cuando un diseñador con Mac deja 300 imágenes con nombres acentuados en un ticket, o cuando un compañero coreano o japonés envía un ZIP cuyos nombres aparecen en Windows como letras separadas (ㅎㅏㄴㄱㅡㄹ en lugar de 한글). Esos archivos hay que renombrarlos antes de que se acerquen a un repositorio, a un CMS o a un servidor, y pedirle a quien los envía que "por favor guarde en NFC" rara vez funciona, porque no ve ninguna diferencia.
Para ese caso, la aplicación web gratuita de FilenameFixer hace la conversión sin escribir ni una línea de script: arrastras los archivos al navegador y los renombra por lotes de NFD a NFC, o le pasas el archivo ZIP (hasta 200 MB) y restaura los nombres que contiene. También convierte texto pegado, algo útil para corregir una lista de rutas en un archivo de configuración. El procesamiento se hace localmente en el navegador, de modo que los archivos no se suben a un servidor, lo que importa cuando se trata de material de clientes. Si tu equipo sube archivos mediante formularios web, la extensión para Chrome corrige los nombres automáticamente en el momento de la subida, lo que cierra el hueco que dejan los navegadores al pasar el NFD sin tocar.
Para cualquier cosa que ya esté dentro de Git, quédate con core.precomposeunicode y git mv. Renombrar archivos en el árbol de trabajo con una herramienta externa sin avisar a Git solo vuelve a crear la misma pareja eliminado/sin seguimiento con la que empezaste.