La lección anterior resolvió qué ficheros no deben entrar en el repositorio. Esta trata del problema complementario y menos conocido: los ficheros que entran, pero que Git no debería tratar a todos por igual.

Y empieza con un problema muy concreto que Carla lleva arrastrando desde el módulo 1. Cada vez que abre estilos.css en su editor de Windows 11, hace un cambio de una línea y ejecuta git diff, aparece esto:

 estilos.css | 486 +++++++++++++++++++++++++-------------------------
 1 fichero modificado, 243 inserciones(+), 243 eliminaciones(-)

243 líneas modificadas por cambiar una. El fichero entero. Y cuando lo confirma, Ana y Bruno reciben una PR irrevisable, git blame queda arruinado (lección 06-03) y cada fusión genera conflictos que no significan nada. El culpable son los finales de línea, y la solución definitiva es .gitattributes.

Este fichero es la herramienta menos conocida y más útil del repertorio de Git. Permite decirle a Git cómo tratar cada tipo de fichero: cómo normalizarlo al guardarlo, si es texto o binario, cómo mostrar sus diferencias, cómo fusionarlo y si debe incluirse al exportar.

Contenido

  1. Qué es .gitattributes y en qué se diferencia de .gitignore
  2. Sintaxis y precedencia
  3. El problema de los finales de línea
  4. text, eol y text=auto
  5. .gitattributes frente a core.autocrlf
  6. Renormalizar un proyecto que ya tiene el problema
  7. Marcar binarios con -text y binary
  8. Diffs personalizados: diff=<driver>
  9. Que @@ muestre el nombre de la función
  10. textconv: ver el texto de ficheros binarios
  11. Drivers de fusión y el caso merge=union
  12. export-ignore y git archive
  13. Otros atributos: filter, linguist-*, export-subst
  14. Tabla resumen

  1. Qué es .gitattributes y en qué se diferencia de .gitignore

Los dos ficheros se parecen —viven en el repositorio, usan patrones de fichero, se versionan— y hacen cosas completamente distintas:

.gitignore .gitattributes
Responde a ¿Debo versionar este fichero? ¿Cómo debo tratar este fichero?
Afecta a Ficheros sin seguimiento Ficheros versionados
Momento de actuación git add, git status checkout, commit, diff, merge, archive
Si te equivocas Subes basura, o no subes algo que hacía falta Diffs ilegibles, conflictos absurdos, ficheros corrompidos
Sintaxis patrón patrón atributo1 atributo2 ...

La forma de una línea de .gitattributes es siempre la misma:

<patrón>  <atributo>  <atributo>=<valor>  -<atributo>
*.js        text eol=lf
*.png       binary
*.md        diff=markdown merge=union
pruebas/    export-ignore
*.pdf       -text diff=pdf

Los cuatro estados posibles de un atributo:

Forma Significado
atributo Activado (valor true)
-atributo Desactivado (valor false)
atributo=valor Con un valor concreto
!atributo Sin especificar: como si no hubiera regla

  1. Sintaxis y precedencia

Los patrones son los mismos que en .gitignore (lección 08-03): *, ?, **, la barra inicial que ancla a la raíz, la barra final que restringe a directorios. Todo lo que aprendiste allí vale aquí.

Las diferencias están en dónde se busca y quién gana:

Ubicación ¿Se versiona? Ámbito
.gitattributes en el repositorio Su directorio y subdirectorios; viaja con el clon
.git/info/attributes No Solo tu copia de ese repositorio
Ruta de core.attributesFile No Todos tus repositorios
$(prefix)/etc/gitattributes No Todo el sistema

Precedencia, de menor a mayor: sistema → global → .git/info/attributes.gitattributes de la raíz → .gitattributes de subdirectorios más profundos. Y dentro de un mismo fichero, gana la última línea que coincide, igual que en .gitignore.

La fila que importa es la primera: .gitattributes se versiona y viaja con el repositorio. Esa propiedad, que parece un detalle administrativo, es exactamente lo que resuelve el problema de Carla, como veremos en el apartado 5.

Para consultar qué atributos se aplican realmente a un fichero:

# Todos los atributos de un fichero
git check-attr -a estilos.css
estilos.css: text: set
estilos.css: eol: lf
estilos.css: diff: css
# Un atributo concreto, en varios ficheros
git check-attr text -- app.js imagenes/logo.png

# Todo lo versionado de golpe
git ls-files | git check-attr --stdin -a

git check-attr es a .gitattributes lo que git check-ignore -v es a .gitignore: la herramienta de diagnóstico. Úsala siempre que algo no se comporte como esperabas.

  1. El problema de los finales de línea

Para resolver el problema de Carla hay que entender su origen, que es histórico y ridículo.

Un salto de línea se codifica de dos formas distintas según el sistema:

Sistema Secuencia Nombre Bytes
Linux, macOS, Unix LF Line Feed 0x0A (\n)
Windows, DOS CRLF Carriage Return + Line Feed 0x0D 0x0A (\r\n)

Viene de las máquinas de escribir: el retorno de carro devolvía el cabezal al principio y el avance de línea bajaba el papel. Unix decidió que un carácter bastaba; DOS mantuvo los dos. Cincuenta años después seguimos pagándolo.

Para Git, \n y \r\n son bytes distintos, así que una línea que solo difiere en el final de línea es una línea distinta. De ahí el 243 inserciones, 243 eliminaciones de Carla: su editor guardó el fichero entero con CRLF, y las 243 líneas cambiaron de verdad, aunque visualmente no cambió ninguna.

flowchart LR
    A["Ana / Ubuntu<br/>guarda con LF"] --> R[("Repositorio")]
    B["Bruno / macOS<br/>guarda con LF"] --> R
    C["Carla / Windows<br/>guarda con CRLF"] --> R
    R --> D["Diffs completos<br/>Conflictos absurdos<br/>blame arruinado"]

Los síntomas típicos, todos con la misma causa:

  • git diff muestra el fichero entero modificado sin cambio visible.
  • git status marca como modificados ficheros que no has tocado.
  • Conflictos de fusión en cada línea de un fichero que solo tocó una persona.
  • git blame atribuye todo al último que guardó desde otro sistema.
  • Un script de shell versionado desde Windows falla en Linux con bad interpreter: /bin/bash^M.

La solución conceptual es una convención que Git implementa de forma nativa:

En el repositorio, todo se guarda con LF. En la copia de trabajo, cada uno tiene lo que su sistema necesita.

La conversión se hace al confirmar (normalización) y al extraer (desnormalización), y la controla el atributo text.

  1. text, eol y text=auto

El atributo text

Declaración Al confirmar (add) Al extraer (checkout)
text Convierte CRLFLF Convierte LF → según core.eol (por defecto, el nativo del sistema)
text=auto Convierte CRLFLF solo si Git detecta que es texto Igual, solo si es texto
-text No toca nada No toca nada
text eol=lf Convierte a LF Siempre LF, en todos los sistemas
text eol=crlf Convierte a LF Siempre CRLF, en todos los sistemas

Los dos matices importantes:

  • eol=lf no significa "guarda LF en el repositorio": en el repositorio siempre hay LF cuando el fichero es text. eol controla qué se escribe en tu disco al extraer.
  • text=auto decide por heurística: Git examina los primeros 8.000 bytes y, si encuentra un byte nulo, lo considera binario. Es una heurística buena, pero no infalible (apartado 7).

El .gitattributes recomendado

Este es el punto de partida que sirve para casi cualquier proyecto:

# Normaliza a LF en el repositorio todo lo que Git detecte como texto.
# Es la red de seguridad general.
* text=auto

Y esta es la versión explícita, que es la que de verdad conviene, porque no depende de ninguna heurística:

# ============================================================
# Regla general: normaliza el texto, deja los binarios en paz
# ============================================================
* text=auto

# ============================================================
# Código fuente: LF en el repositorio y también en el disco
# ============================================================
*.js       text eol=lf
*.mjs      text eol=lf
*.json     text eol=lf
*.css      text eol=lf
*.html     text eol=lf
*.md       text eol=lf
*.yml      text eol=lf
*.yaml     text eol=lf
*.svg      text eol=lf

# ============================================================
# Ficheros que EXIGEN LF aunque estés en Windows
# ============================================================
*.sh       text eol=lf
*.bash     text eol=lf
Dockerfile text eol=lf
Makefile   text eol=lf
.gitattributes text eol=lf
.gitignore     text eol=lf

# ============================================================
# Ficheros que EXIGEN CRLF aunque estés en Linux
# ============================================================
*.bat      text eol=crlf
*.cmd      text eol=crlf
*.ps1      text eol=crlf

# ============================================================
# Binarios: no tocar bajo ningún concepto
# ============================================================
*.png      binary
*.jpg      binary
*.jpeg     binary
*.gif      binary
*.webp     binary
*.ico      binary
*.pdf      binary
*.zip      binary
*.woff     binary
*.woff2    binary
*.ttf      binary

Las dos secciones del medio son las que evitan fallos reales:

  • *.sh text eol=lf: un script de shell con CRLF no arranca en Linux. El intérprete busca /bin/bash y encuentra /bin/bash\r. Con esta regla, aunque Carla lo edite en Windows, en su disco tendrá LF y funcionará en el contenedor.
  • *.bat text eol=crlf: los .bat antiguos de Windows necesitan CRLF. Aunque Ana los edite en Ubuntu, en el disco de Carla llegarán con CRLF.

  1. .gitattributes frente a core.autocrlf

En la lección 01-06 vimos core.autocrlf, que resuelve el mismo problema desde el otro lado. Es el momento de comparar los dos enfoques y explicar por qué uno gana.

core.autocrlf es configuración de máquina (lección 01-05), con tres valores:

Valor Al confirmar Al extraer Recomendado para
true CRLFLF LFCRLF Windows
input CRLFLF Sin conversión Linux y macOS
false Nada Nada Por defecto; deja el problema tal cual
# Lo que Carla configuró en su día
git config --global core.autocrlf true

# Lo que configuraron Ana y Bruno
git config --global core.autocrlf input

Funciona, y si todo el mundo lo tiene bien configurado el problema desaparece. El problema es "si todo el mundo lo tiene bien configurado".

core.autocrlf .gitattributes
Dónde vive En .gitconfig de cada máquina En el repositorio
¿Viaja con el clon? No
¿Quién lo aplica? Cada persona, si se acuerda Todo el mundo, automáticamente
Granularidad Todo o nada, para todos los repositorios Por patrón de fichero
¿Puede forzar LF en Windows? No Sí, con eol=lf
¿Puede forzar CRLF en Linux? No Sí, con eol=crlf
Si alguien lo tiene mal Contamina el repositorio para todos Da igual: los atributos mandan
Colaborador externo (Diego) Depende de su configuración Se le aplica al clonar el fork
Prioridad Menor Mayor: gana siempre

La última fila es la clave técnica: cuando un fichero tiene el atributo text definido, core.autocrlf se ignora por completo para ese fichero. Los atributos ganan.

Y las dos filas anteriores son las prácticas. Cuando Diego, el colaborador externo, clona su fork de gestor-tareas, nadie puede pedirle que configure su core.autocrlf antes de tocar nada. Con .gitattributes, no hace falta: la política viaja dentro del repositorio y se le aplica sola.

La regla: .gitattributes para la política del proyecto; core.autocrlf como red de seguridad personal en repositorios que no lo tengan. Si tienes .gitattributes, core.autocrlf sobra.

Hay además un ajuste útil que detecta el problema antes de que entre:

# Rechaza confirmar un fichero que mezcla CRLF y LF
git config --global core.safecrlf true

# Solo avisar, sin bloquear
git config --global core.safecrlf warn

  1. Renormalizar un proyecto que ya tiene el problema

Añadir el .gitattributes no arregla el pasado. Los ficheros ya confirmados con CRLF siguen con CRLF dentro del repositorio, y hasta que alguien los toque el problema persiste. Hay que renormalizar, y Git tiene un comando exactamente para eso.

# 1. Asegúrate de que no hay cambios sin confirmar. Esto es innegociable.
git status
git stash push -u   # si hace falta apartar algo (lección 05-04)

# 2. Crea o actualiza el .gitattributes y confírmalo por separado
git add .gitattributes
git commit -m "chore: define la política de finales de línea en .gitattributes"

# 3. Renormaliza todo el repositorio
git add --renormalize .

# 4. Mira qué va a cambiar
git status --short
git diff --cached --stat

# 5. Confírmalo en su PROPIO commit, aislado
git commit -m "chore: renormaliza los finales de línea a LF

Aplica la política de .gitattributes al contenido ya versionado.
Este commit no cambia ni una línea de código: solo sustituye CRLF
por LF en el contenido almacenado. Se registra en
.git-blame-ignore-revs.

Refs: GT-190"

Qué hace git add --renormalize

Reescribe el contenido de cada fichero seguido en el índice aplicando los filtros de .gitattributes a la versión que hay en el repositorio, sin tocar tu copia de trabajo. Es la forma segura de decir "aplica la política nueva a todo lo que ya está dentro".

Lo que resulta es exactamente el caso del apartado 11 de la lección 08-02: un commit gigantesco que no cambia ni una coma de comportamiento y que arruina git blame. Así que se le aplica el mismo tratamiento:

# Registrarlo para que blame lo atraviese
git rev-parse HEAD >> .git-blame-ignore-revs
git add .git-blame-ignore-revs
git commit -m "chore: registra la renormalización en blame-ignore-revs"

La coordinación con el equipo

Renormalizar toca todos los ficheros, así que cualquier rama abierta en ese momento entrará en conflicto masivo al fusionarse. La secuencia sensata:

  1. Avisa con antelación y fija una hora.
  2. Que todo el mundo fusione o cierre sus ramas antes de esa hora.
  3. Una persona hace la renormalización en una PR propia, sin nada más dentro.
  4. Se fusiona en cuanto está aprobada, sin dejarla abierta días.
  5. Todo el mundo hace pull y, si tenía ramas vivas, las rebasa sobre el commit de renormalización.
  6. Si alguien tiene conflictos solo de finales de línea, se resuelven en bloque:
# Quedarse con la versión de main y renormalizar
git checkout --ours -- .    # o --theirs, según el caso
git add --renormalize .

Una comprobación previa muy útil, para ver cuánto daño hay antes de empezar:

# Lista los ficheros versionados que contienen CRLF
git ls-files --eol | grep 'w/crlf'
i/lf    w/crlf  attr/text=auto     estilos.css
i/crlf  w/crlf  attr/                index.html
i/lf    w/lf    attr/text=auto     app.js

Se lee: i/ es el final de línea en el índice (el repositorio) y w/ el de tu copia de trabajo. La línea de index.html con i/crlf es la que revela el problema: hay CRLF dentro del repositorio, que es lo que hay que renormalizar.

  1. Marcar binarios con -text y binary

Un fichero binario no debe convertirse jamás. Si Git aplica la conversión de finales de línea a un PNG, lo corrompe: cualquier byte 0x0D 0x0A dentro de los datos de la imagen se convierte en 0x0A y el fichero deja de ser válido.

text=auto detecta la mayoría de los binarios por la heurística del byte nulo, pero falla en casos reales: ficheros comprimidos sin bytes nulos en los primeros 8 KB, formatos de datos mixtos, ficheros de fuente con cabecera de texto. Declararlos explícitamente es más barato que descubrir el problema.

*.png   binary
*.pdf   binary
*.zip   binary

binary es un macro-atributo: es exactamente equivalente a escribir

*.png   -text -diff -merge

Es decir, tres cosas a la vez:

Atributo Efecto
-text No convertir finales de línea (evita la corrupción)
-diff git diff no intenta mostrar el contenido; dice Binary files differ
-merge En un conflicto, no intenta fusionar línea a línea

El efecto de -merge merece explicarse, porque cambia lo que ves en un conflicto. Sin él, Git intentaría mezclar dos versiones de un PNG y produciría un fichero corrupto con marcadores <<<<<<< dentro. Con él, el conflicto se plantea como una elección entre las dos versiones enteras:

warning: Cannot merge binary files: imagenes/logo.png (HEAD vs. rama-diseno)
Auto-fusionando imagenes/logo.png
CONFLICTO (contenido): Conflicto de fusión en imagenes/logo.png

Y se resuelve eligiendo una de las dos, como vimos en la lección 03-05:

git checkout --ours   imagenes/logo.png   # la de mi rama
git checkout --theirs imagenes/logo.png   # la de la otra rama
git add imagenes/logo.png

Cuándo usar -text a secas en vez de binary

Cuando el fichero no es texto normalizable pero sí quieres poder verlo en un diff. El caso típico es un SVG grande o un fichero de datos con codificación fija:

*.svg   -text diff

Y hay un caso especial que conviene conocer: un fichero de texto en una codificación de dos bytes por carácter, como UTF-16, contiene bytes nulos y Git lo clasificará como binario. Se corrige con working-tree-encoding:

*.rc    working-tree-encoding=UTF-16LE text eol=crlf

Git guardará el contenido en UTF-8 dentro del repositorio (donde los diffs funcionan) y lo escribirá en UTF-16LE en tu disco.

  1. Diffs personalizados: diff=<driver>

Git sabe mostrar diferencias de texto plano. Con diff=<driver> se le puede dar contexto sobre el tipo de fichero, y el resultado mejora mucho.

Los drivers integrados

Git trae de fábrica reconocedores de función para muchos lenguajes. Solo hay que declararlos:

*.js     diff=javascript
*.ts     diff=typescript
*.css    diff=css
*.html   diff=html
*.md     diff=markdown
*.py     diff=python
*.java   diff=java
*.rb     diff=ruby
*.php    diff=php
*.go     diff=golang
*.rs     diff=rust
*.tex    diff=tex
*.json   diff=json

La lista completa está en git help attributes. Hay drivers para Ada, Bash, C/C++, C#, Dts, Elixir, Fortran, Fountain, Kotlin, MATLAB, Objective-C, Perl, Scheme y algunos más.

  1. Que @@ muestre el nombre de la función

Aquí está el beneficio concreto. Recuerda de la lección 02-05 que cada trozo de un diff empieza con una línea @@:

Sin driver:

@@ -142,7 +142,7 @@
   const etiquetas = tarea.etiquetas || [];
-  if (etiquetas.includes(filtro)) {
+  if (etiquetas.some(e => e.toLowerCase() === filtro.toLowerCase())) {
     return true;
   }

Con *.js diff=javascript:

@@ -142,7 +142,7 @@ function coincideConFiltro(tarea, filtro) {
   const etiquetas = tarea.etiquetas || [];
-  if (etiquetas.includes(filtro)) {
+  if (etiquetas.some(e => e.toLowerCase() === filtro.toLowerCase())) {
     return true;
   }

Git ha añadido function coincideConFiltro(tarea, filtro) al final de la línea @@. Ahora sabes qué función estás leyendo sin abrir el fichero. Multiplicado por los cuarenta trozos de una revisión, es la diferencia entre revisar con contexto y revisar a ciegas.

Ese contexto aparece automáticamente en git diff, git show, git log -p y en las plataformas de revisión. Y funciona también con la búsqueda por función de la lección 02-05:

# Todo el historial de una función concreta
git log -L :coincideConFiltro:app.js

Sin el driver, -L :nombre: no sabe encontrar la función.

Definir tu propio reconocedor

Si tu lenguaje no tiene driver, o el integrado no reconoce tu estilo, se define con una expresión regular. Son dos piezas: la declaración en .gitattributes (versionada) y la definición en la configuración de Git (local, porque .gitattributes no puede contener comandos).

# .gitattributes
*.js   diff=jsmoderno
# Definir qué líneas cuentan como "cabecera de función"
git config --local diff.jsmoderno.xfuncname \
  '^[[:space:]]*((export[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*|const[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?\().*$'

La expresión reconoce function nombre(, export function nombre(, async function nombre( y const nombre = ( o const nombre = async (, que cubre el estilo moderno de JavaScript.

Recuerda la limitación de fondo: .gitattributes declara qué driver usar; la definición del driver vive en git config y no se versiona. Documéntala en el README.md o en el script de arranque del repositorio, junto a core.hooksPath y blame.ignoreRevsFile de las lecciones anteriores.

  1. textconv: ver el texto de ficheros binarios

Hay binarios que contienen texto: un PDF, un .docx, una hoja de cálculo, un fichero de imagen con metadatos. Por defecto, git diff sobre ellos dice Binary files differ, que es cierto e inútil.

textconv le da a Git un comando que convierte el binario en texto, y Git compara ese texto:

*.pdf    diff=pdf
*.docx   diff=word
*.png    diff=exif
# Definiciones (locales, no versionadas)
git config --local diff.pdf.textconv  "pdftotext -layout"
git config --local diff.word.textconv "pandoc --to=plain"
git config --local diff.exif.textconv "exiftool"

# Cachear el resultado: la conversión es cara
git config --local diff.pdf.cachetextconv true

Cada comando recibe la ruta del fichero como argumento y debe escribir texto en la salida estándar.

Ahora, si Bruno cambia el manual de usuario:

git diff manual.pdf
diff --git a/manual.pdf b/manual.pdf
index 3a4b5c6..7d8e9f0 100644
--- a/manual.pdf
+++ b/manual.pdf
@@ -12,7 +12,7 @@
 Para crear una tarea, pulsa el botón "Nueva tarea".
-El título es obligatorio y admite hasta 80 caracteres.
+El título es obligatorio y admite hasta 200 caracteres.
 Puedes asignar etiquetas separadas por comas.

Lo que hay que entender bien: textconv afecta solo a la visualización. El contenido almacenado sigue siendo el PDF binario íntegro, y las fusiones siguen sin poder hacerse. Es una lente de lectura, no un cambio de formato.

Requisito práctico: la herramienta externa (pdftotext, pandoc, exiftool) tiene que estar instalada. Si no lo está, git diff falla con un error. Por eso textconv es un buen candidato para el .git/info/attributes local de quien tenga esas herramientas, en lugar del .gitattributes compartido.

  1. Drivers de fusión y el caso merge=union

Igual que con los diffs, se puede cambiar cómo se fusiona un tipo de fichero.

El problema

Cada semana, Ana, Bruno y Carla añaden una línea al CHANGELOG.md, cada uno en su rama, siempre al principio:

## Sin publicar
- Añade el filtro por etiqueta (GT-134)

Al fusionar, conflicto garantizado, tres veces por semana. Y es un conflicto falso: las tres líneas deben quedarse, no hay ninguna decisión que tomar.

merge=union

CHANGELOG.md   merge=union

union es un driver integrado (no hay que definir nada) que, ante un conflicto, conserva las líneas de ambos lados, sin marcadores. En el orden: primero las de la rama base, luego las del otro lado.

Con esto, la fusión produce:

## Sin publicar
- Añade el filtro por etiqueta (GT-134)
- Guarda las tareas en IndexedDB (GT-137)
- Corrige el orden de las tareas vencidas (GT-141)

Sin conflicto y sin intervención.

Cuándo usarlo: ficheros que son listas acumulativas y donde el orden no es semántico.

Buen candidato Mal candidato
CHANGELOG.md, AUTHORS, CONTRIBUTORS Cualquier fichero de código
Ficheros de registro que se versionan package.json (dos versiones de la misma dependencia = JSON inválido)
Listas de reglas donde el orden da igual Cualquier fichero de configuración estructurado

Advertencia seria: merge=union nunca debe aplicarse a código. Combina las dos versiones de una función y produce algo sintácticamente roto sin avisar de nada. El silencio es su ventaja y su peligro: elimina el conflicto, no lo resuelve.

Los otros drivers integrados

Driver Comportamiento
merge=text El comportamiento normal de fusión de tres bandas (lección 03-03)
merge=binary No intenta fusionar; conflicto para elegir una versión entera
merge=union Conserva las líneas de ambos lados, sin marcadores
-merge Equivalente a merge=binary

Un driver propio

Para casos como "en un conflicto sobre un fichero generado, quédate con el mío y regenera":

package-lock.json   merge=bloqueo-npm
git config --local merge.bloqueo-npm.name "Regenera el bloqueo de npm"
git config --local merge.bloqueo-npm.driver \
  'npm install --package-lock-only --silent && cp package-lock.json %A'

Git sustituye los marcadores en el comando: %O es la versión base común, %A la tuya (y donde debe quedar el resultado), %B la del otro lado y %L el tamaño del marcador de conflicto. El driver debe devolver 0 si resolvió y distinto de 0 si hay que resolver a mano.

Y una advertencia: si Diego clona el fork y no tiene esa configuración local, Git usará el driver por defecto. Los drivers propios se degradan silenciosamente, así que no deben ser la única línea de defensa.

  1. export-ignore y git archive

git archive empaqueta el contenido de un commit en un .tar o .zip, sin el directorio .git. Es la forma estándar de producir un paquete distribuible:

git archive --format=zip --output=/tmp/gestor-tareas-1.5.0.zip v1.5.0
git archive --format=tar.gz --prefix=gestor-tareas/ -o /tmp/paq.tar.gz HEAD

El atributo export-ignore marca lo que no debe entrar en ese paquete. Es distinto de .gitignore: estos ficheros sí están versionados; simplemente no interesan a quien descarga el paquete.

# Nada de esto tiene sentido en un paquete distribuible
.gitattributes   export-ignore
.gitignore       export-ignore
.github/         export-ignore
.githooks/       export-ignore
pruebas/         export-ignore
docs/interno/    export-ignore
.editorconfig    export-ignore
CONTRIBUTING.md  export-ignore
.git-blame-ignore-revs export-ignore

Comprueba el resultado antes de publicar:

git archive --format=tar HEAD | tar -t

Un uso muy práctico: las plataformas de alojamiento generan automáticamente el .zip y el .tar.gz de cada etiqueta usando git archive, así que export-ignore controla qué contienen los paquetes de tus versiones sin que tengas que hacer nada más.

export-subst

Sustituye marcadores por información del commit al exportar:

VERSION.txt   export-subst
Versión: $Format:%(describe:tags)$
Commit:  $Format:%H$
Fecha:   $Format:%cI$

Al hacer git archive, el fichero del paquete sale con los valores rellenos:

Versión: v1.5.0
Commit:  9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e
Fecha:   2026-08-01T10:22:14+02:00

Es la forma limpia de que un paquete distribuido sepa de qué commit salió, y se combina bien con las etiquetas anotadas de la lección 05-05. Ojo: los marcadores solo se sustituyen en el paquete, no en tu copia de trabajo.

  1. Otros atributos: filter, linguist-*, export-subst

filter: transformar al entrar y al salir

El atributo filter define un par de conversiones: clean al confirmar (de tu disco al repositorio) y smudge al extraer (del repositorio a tu disco).

flowchart LR
    W["Copia de trabajo"] -->|"filtro clean<br/>(git add)"| R[("Repositorio")]
    R -->|"filtro smudge<br/>(git checkout)"| W

Su uso más importante con diferencia es Git LFS, que almacena los ficheros grandes fuera del repositorio y deja dentro un puntero de texto:

*.psd    filter=lfs diff=lfs merge=lfs -text
*.mp4    filter=lfs diff=lfs merge=lfs -text
*.zip    filter=lfs diff=lfs merge=lfs -text

Esas líneas las escribe git lfs track por ti. El filter=lfs sustituye el fichero por un puntero al confirmar y lo recupera al extraer. Git LFS es el contenido de la lección 10-03, donde se explica el mecanismo completo, el servidor de almacenamiento y sus implicaciones; aquí solo interesa saber que es .gitattributes quien lo activa.

Un ejemplo propio, para entender el mecanismo (aunque en la práctica es mejor no usarlo):

*.md   filter=quitar-espacios-finales
git config --local filter.quitar-espacios-finales.clean "sed 's/[[:space:]]*$//'"
git config --local filter.quitar-espacios-finales.smudge cat

Cuidado con los filtros propios. Si el filtro no es idempotente, o si alguien clona sin la configuración, el contenido se descuadra: git status marca ficheros como modificados sin que nadie los haya tocado. Es una fuente clásica de confusión y por eso conviene reservarlos para casos donde no haya alternativa.

linguist-*: cómo te clasifica la plataforma

Las plataformas de alojamiento usan una biblioteca llamada Linguist para deducir en qué lenguajes está escrito tu proyecto y qué mostrar en la vista de diferencias. Se ajusta con estos atributos:

# No contar esto en las estadísticas de lenguajes
vendor/*           linguist-vendored
docs/ejemplos/*    linguist-documentation

# Sí contarlo, aunque esté en un directorio que parece de terceros
lib/propio.js      -linguist-vendored

# Corregir una detección errónea
*.inc              linguist-language=PHP

# Colapsar por defecto en la vista de diferencias
*.min.js           linguist-generated
package-lock.json  linguist-generated

linguist-generated es el más útil en el día a día: hace que los ficheros generados aparezcan colapsados en las revisiones, lo que elimina de golpe el ruido de un package-lock.json de 8.000 líneas en una PR. Enlaza directamente con lo que dijimos sobre el tamaño de la PR en la lección 07-02.

Estos atributos no afectan a Git: son convenciones que interpretan las plataformas. Si tu servidor git.ejemplo.es no usa Linguist, no hacen nada.

  1. Tabla resumen

Atributo Qué hace Ejemplo
text Normaliza a LF en el repositorio *.js text
text=auto Normaliza solo si detecta texto * text=auto
-text No convertir nunca *.png -text
eol=lf En el disco, siempre LF *.sh text eol=lf
eol=crlf En el disco, siempre CRLF *.bat text eol=crlf
binary Macro: -text -diff -merge *.pdf binary
diff=<driver> Contexto de función en @@, o textconv *.css diff=css
-diff No mostrar el contenido en los diffs *.zip -diff
merge=union Conserva las líneas de ambos lados CHANGELOG.md merge=union
merge=<driver> Estrategia de fusión propia *.lock merge=bloqueo
-merge Sin fusión automática: elegir una versión *.png -merge
filter=<f> Transforma al entrar y al salir *.psd filter=lfs
export-ignore Excluir de git archive pruebas/ export-ignore
export-subst Sustituir $Format:...$ al exportar VERSION.txt export-subst
working-tree-encoding Codificación distinta en el disco *.rc working-tree-encoding=UTF-16LE
whitespace Qué considerar error de espaciado *.py whitespace=tab-in-indent
linguist-generated Colapsar en la vista de diferencias *.min.js linguist-generated
linguist-vendored Excluir de las estadísticas vendor/* linguist-vendored

El .gitattributes de gestor-tareas

# ============================================================
# Finales de línea (resuelve GT-190)
# ============================================================
* text=auto

*.js       text eol=lf diff=javascript
*.css      text eol=lf diff=css
*.html     text eol=lf diff=html
*.json     text eol=lf
*.md       text eol=lf diff=markdown
*.yml      text eol=lf
*.sh       text eol=lf
*.bat      text eol=crlf

# ============================================================
# Binarios
# ============================================================
*.png      binary
*.jpg      binary
*.webp     binary
*.ico      binary
*.woff2    binary
*.pdf      binary diff=pdf

# ============================================================
# Fusión especial
# ============================================================
CHANGELOG.md   merge=union

# ============================================================
# Vista de diferencias en la plataforma
# ============================================================
package-lock.json  linguist-generated
*.min.js           linguist-generated
*.min.css          linguist-generated

# ============================================================
# Fuera del paquete distribuible
# ============================================================
.gitattributes         export-ignore
.gitignore             export-ignore
.git-blame-ignore-revs export-ignore
.github/               export-ignore
.githooks/             export-ignore
pruebas/               export-ignore
CONTRIBUTING.md        export-ignore

Errores Comunes y Consejos

Error 1: crear el .gitattributes y creer que ya está. No arregla el contenido ya confirmado. Hace falta git add --renormalize . en un commit aparte.

Error 2: renormalizar mezclado con otros cambios. Produce un commit ilegible que además no se puede registrar en .git-blame-ignore-revs, porque ignorarlo ocultaría también el cambio funcional. Aíslalo, siempre.

Error 3: renormalizar sin avisar al equipo. Todas las ramas abiertas entran en conflicto masivo. Coordínalo, hazlo en su propia PR y fusiónalo rápido.

Error 4: confiar solo en core.autocrlf. No viaja con el repositorio. Basta con que Diego no lo tenga configurado para que el problema vuelva.

Error 5: aplicar merge=union a código. Combina las dos versiones de una función y produce código roto sin avisar. Solo para listas acumulativas.

Error 6: no marcar los binarios. text=auto acierta casi siempre, pero cuando falla corrompe el fichero. Declararlos cuesta una línea.

Error 7: olvidar que las definiciones de driver no se versionan. .gitattributes dice diff=jsmoderno; la definición vive en git config y no viaja. Documéntala en el README.md.

Error 8: export-ignore sobre ficheros que la gente sí necesita. Comprueba siempre el resultado con git archive --format=tar HEAD | tar -t.

Consejo 1: * text=auto en el primer commit de todo proyecto nuevo. Una línea que evita años de problemas.

Consejo 2: eol=lf explícito en los scripts. *.sh text eol=lf evita el bad interpreter: /bin/bash^M que aparece la primera vez que un script editado en Windows llega a un contenedor.

Consejo 3: git check-attr -a <fichero> cuando algo no cuadre. Y git ls-files --eol para ver el estado real de los finales de línea del repositorio.

Consejo 4: declara los drivers de diff de tus lenguajes. Es la mejora de legibilidad más barata de las revisiones: el nombre de la función en cada @@.

Consejo 5: linguist-generated en los ficheros de bloqueo. Colapsa 8.000 líneas de ruido en cada PR.

Consejo 6: para binarios grandes, .gitattributes no es la respuesta. Marcarlos como binary evita corromperlos, pero siguen ocupando el mismo espacio en el historial. Eso lo resuelven Git LFS (lección 10-03) y los consejos de la lección siguiente.

Ejercicios

Ejercicio 1: reproducir y arreglar el problema de Carla

  1. Crea un repositorio con un estilos.css de 10 líneas, guardado con LF, y confírmalo sin .gitattributes.
  2. Simula el guardado desde Windows convirtiendo el fichero a CRLF:
    sed -i 's/$/\r/' estilos.css
    
  3. Ejecuta git diff --stat y comprueba que las 10 líneas aparecen modificadas.
  4. Comprueba el estado real con git ls-files --eol.
  5. Añade un .gitattributes con *.css text eol=lf, confírmalo, y demuestra que el diff sigue igual de roto hasta que renormalizas.
  6. Renormaliza correctamente y verifica con git ls-files --eol que el índice queda con i/lf.

Ejercicio 2: diffs con contexto de función

  1. Crea un app.js con tres funciones de unas cinco líneas cada una y confírmalo.
  2. Modifica una línea dentro de la segunda función y ejecuta git diff. Observa la línea @@.
  3. Añade *.js diff=javascript al .gitattributes y repite el git diff. Compara.
  4. Prueba git log -L :nombreDeLaFuncion:app.js y explica qué hace.
  5. Añade un *.pdf diff=pdf con un textconv (usa pdftotext si lo tienes, o simula uno con un script propio que convierta un formato inventado a texto) y demuestra que git diff muestra el contenido.

Ejercicio 3: merge=union y export-ignore

  1. Crea un repositorio con un CHANGELOG.md que tenga una cabecera y una entrada.
  2. Crea dos ramas y añade en cada una una entrada distinta en la misma posición, al principio de la lista.
  3. Fusiona la segunda en la primera y comprueba el conflicto.
  4. Aborta la fusión, añade CHANGELOG.md merge=union al .gitattributes, confírmalo y repite la fusión. Verifica el resultado.
  5. Explica por qué esta misma configuración sobre app.js sería peligrosa, con un ejemplo concreto de código roto.
  6. Añade pruebas/ export-ignore y .gitattributes export-ignore, y demuestra con git archive --format=tar HEAD | tar -t que no aparecen en el paquete.

Soluciones

Solución 1:

mkdir /tmp/practica-eol && cd /tmp/practica-eol && git init -b main
printf '.tarea {\n  color: #333;\n  padding: 8px;\n}\n.tarea.completada {\n  opacity: 0.5;\n}\n.tarea.vencida {\n  border-left: 3px solid #c00;\n}\n' > estilos.css
git add . && git commit -m "feat(css): estilos base"
# 2 y 3. Simular el guardado desde Windows
sed -i 's/$/\r/' estilos.css
git diff --stat
 estilos.css | 20 ++++++++++----------
 1 fichero modificado, 10 inserciones(+), 10 eliminaciones(-)

Diez líneas modificadas sin haber cambiado nada visible. Exactamente el problema de Carla.

# 4. El estado real
git ls-files --eol
i/lf    w/crlf  attr/                estilos.css

i/lf: el índice tiene LF. w/crlf: el disco tiene CRLF. attr/ vacío: no hay ninguna regla aplicándose.

# 5. El .gitattributes por sí solo no arregla nada
echo "*.css text eol=lf" > .gitattributes
git add .gitattributes && git commit -m "chore: política de finales de línea"

git diff --stat
 estilos.css | 20 ++++++++++----------

Sigue igual. El atributo se aplicará a partir de ahora, pero el contenido del disco sigue con CRLF y Git sigue viendo diferencia.

# 6. Renormalizar
git add --renormalize .
git status --short
M  estilos.css
git commit -m "chore: renormaliza los finales de línea a LF"
git checkout -- .          # forzar la reescritura del disco según los atributos
git ls-files --eol
i/lf    w/lf    attr/text eol=lf    estilos.css

Índice LF, disco LF, atributo aplicado. git diff vuelve a estar limpio, y a partir de ahora, aunque Carla guarde con CRLF, Git lo normalizará al confirmar.

Solución 2:

mkdir /tmp/practica-diff && cd /tmp/practica-diff && git init -b main
cat > app.js <<'EOF'
function crearTarea(titulo, etiquetas) {
  if (!titulo) throw new Error("El título es obligatorio");
  const id = Date.now().toString(36);
  return { id, titulo, etiquetas: etiquetas || [], completada: false };
}

function coincideConFiltro(tarea, filtro) {
  if (!filtro) return true;
  const etiquetas = tarea.etiquetas || [];
  return etiquetas.includes(filtro);
}

function contarPendientes(tareas) {
  return tareas.filter(t => !t.completada).length;
}
EOF
git add . && git commit -m "feat(app): funciones base"
# 2. Sin driver
sed -i 's/return etiquetas.includes(filtro);/return etiquetas.some(e => e.toLowerCase() === filtro.toLowerCase());/' app.js
git diff
@@ -7,7 +7,7 @@
 function coincideConFiltro(tarea, filtro) {
   if (!filtro) return true;
   const etiquetas = tarea.etiquetas || [];
-  return etiquetas.includes(filtro);
+  return etiquetas.some(e => e.toLowerCase() === filtro.toLowerCase());
 }

La línea @@ está desnuda. Aquí la función se ve por casualidad, porque el trozo es pequeño; con un contexto de tres líneas dentro de una función de sesenta, no se vería.

# 3. Con driver
echo "*.js diff=javascript" > .gitattributes
git diff
@@ -7,7 +7,7 @@ function coincideConFiltro(tarea, filtro) {

Git ha añadido la firma de la función. Comprobación de que el atributo se aplica:

git check-attr -a app.js
app.js: diff: javascript
# 4. Historial de una función
git add . && git commit -m "refactor(filtros): compara etiquetas sin distinguir mayúsculas"
git log -L :coincideConFiltro:app.js

Muestra solo la evolución de esa función a lo largo del historial, con el diff de cada cambio que la tocó. Para localizar la función, -L :nombre: usa el mismo reconocedor que declara diff=javascript; sin él, la búsqueda por nombre no funciona de forma fiable.

# 5. textconv con un formato inventado
cat > /tmp/mostrar-inventado.sh <<'EOF'
#!/usr/bin/env bash
# Convierte a texto un formato binario ficticio: base64 en el fichero
base64 -d "$1" 2>/dev/null || echo "(no legible)"
EOF
chmod +x /tmp/mostrar-inventado.sh

echo "*.inv diff=inventado" >> .gitattributes
git config --local diff.inventado.textconv /tmp/mostrar-inventado.sh
git config --local diff.inventado.cachetextconv true

echo "Manual del gestor de tareas, version 1" | base64 > manual.inv
git add . && git commit -m "docs: añade el manual"

echo "Manual del gestor de tareas, version 2" | base64 > manual.inv
git diff manual.inv
diff --git a/manual.inv b/manual.inv
--- a/manual.inv
+++ b/manual.inv
@@ -1 +1 @@
-Manual del gestor de tareas, version 1
+Manual del gestor de tareas, version 2

Git compara la salida del comando, no los bytes. El contenido almacenado sigue siendo el fichero original íntegro.

Solución 3:

mkdir /tmp/practica-union && cd /tmp/practica-union && git init -b main
cat > CHANGELOG.md <<'EOF'
# Registro de cambios

## Sin publicar
- Publica la versión inicial (GT-100)
EOF
git add . && git commit -m "docs: añade el registro de cambios"
# 2. Dos ramas, dos entradas en la misma posición
git switch -c GT-134
sed -i '4i - Añade el filtro por etiqueta (GT-134)' CHANGELOG.md
git commit -am "docs: registra GT-134"

git switch main && git switch -c GT-137
sed -i '4i - Guarda las tareas en IndexedDB (GT-137)' CHANGELOG.md
git commit -am "docs: registra GT-137"
# 3. El conflicto
git switch GT-134
git merge GT-137
Auto-fusionando CHANGELOG.md
CONFLICTO (contenido): Conflicto de fusión en CHANGELOG.md
## Sin publicar
<<<<<<< HEAD
- Añade el filtro por etiqueta (GT-134)
=======
- Guarda las tareas en IndexedDB (GT-137)
>>>>>>> GT-137
- Publica la versión inicial (GT-100)
# 4. Con merge=union
git merge --abort
echo "CHANGELOG.md merge=union" > .gitattributes
git add . && git commit -m "chore: fusión por unión del registro de cambios"

git merge GT-137
cat CHANGELOG.md
# Registro de cambios

## Sin publicar
- Añade el filtro por etiqueta (GT-134)
- Guarda las tareas en IndexedDB (GT-137)
- Publica la versión inicial (GT-100)

Sin conflicto y con las dos entradas. (Si merge falla por no encontrar el fichero de atributos en el commit base, confirma primero el .gitattributes en main y rebasa las ramas: los atributos se leen de la copia de trabajo en el momento de la fusión.)

5. Por qué sería peligroso en código. Supón que Ana y Bruno modifican la misma función:

// Rama de Ana
function contarPendientes(tareas) {
  return tareas.filter(t => !t.completada).length;
}

// Rama de Bruno
function contarPendientes(tareas) {
  return tareas.filter(t => !t.completada && !t.archivada).length;
}

Con merge=union, el resultado sería:

function contarPendientes(tareas) {
  return tareas.filter(t => !t.completada).length;
  return tareas.filter(t => !t.completada && !t.archivada).length;
}

Sintácticamente válido, silenciosamente incorrecto: la segunda línea es código muerto y el cambio de Bruno se pierde sin que nadie se entere. En otros casos el resultado ni siquiera compila, y en el peor —dos versiones de una condición o de una llave— produce código que hace algo distinto de lo que ninguno de los dos quería. Y todo ello sin ningún conflicto que avise.

Esa es la clave: merge=union elimina el conflicto, no lo resuelve. Solo es aceptable cuando "quedarse con ambas líneas" es siempre la respuesta correcta, es decir, en listas acumulativas donde el orden no es semántico.

# 6. export-ignore
mkdir pruebas && echo "test('ok', () => {});" > pruebas/app.test.js
cat >> .gitattributes <<'EOF'
pruebas/       export-ignore
.gitattributes export-ignore
EOF
git add . && git commit -m "chore: excluye pruebas del paquete distribuible"

git archive --format=tar HEAD | tar -t
CHANGELOG.md

Ni pruebas/ ni .gitattributes aparecen: están versionados, pero fuera del paquete.

Conclusión

Lo esencial de esta lección:

  • .gitignore decide qué versionar; .gitattributes decide cómo tratar lo versionado. Actúa en checkout, commit, diff, merge y archive, y su diagnóstico es git check-attr -a.
  • El problema de los finales de línea —el de Carla— se resuelve con una convención: LF en el repositorio, lo nativo en el disco. Se declara con text, text=auto y eol=lf/eol=crlf, y eol controla el disco, no el repositorio.
  • .gitattributes gana a core.autocrlf y, sobre todo, viaja con el repositorio. Esa es la razón de fondo por la que es la solución robusta: la política se aplica a todo el mundo, incluido Diego con su fork, sin depender de que nadie configure nada.
  • Añadir el fichero no arregla el pasado: hace falta git add --renormalize ., en un commit aislado, coordinado con el equipo y registrado en .git-blame-ignore-revs (lección 08-02). git ls-files --eol muestra el estado real antes y después.
  • binary es el macro de -text -diff -merge y evita que Git corrompa un fichero binario intentando convertir sus bytes. Declarar los binarios explícitamente cuesta una línea y ahorra sorpresas.
  • diff=<driver> hace que la línea @@ muestre el nombre de la función, y habilita git log -L :funcion:fichero. textconv permite ver como texto el contenido de un binario, solo para visualizar.
  • merge=union conserva las líneas de ambos lados y elimina los conflictos falsos de CHANGELOG.md. Nunca en código: elimina el conflicto, no lo resuelve.
  • export-ignore controla qué contienen los paquetes de git archive —incluidos los que generan las plataformas para cada etiqueta—, y export-subst rellena la versión y el commit en el paquete.
  • filter=lfs es la puerta de entrada a Git LFS, cuyo mecanismo completo se ve en la lección 10-03; y los atributos linguist-*, aunque no afectan a Git, colapsan el ruido de los ficheros generados en las revisiones.

gestor-tareas ya no versiona lo que no debe, y trata cada fichero como corresponde. Queda pendiente el asunto más grave de los que anunciamos al cerrar el módulo 7, y el que no admite improvisación: el fichero de configuración con la contraseña de la base de datos que lleva meses en el historial.

Sacarlo de ahí tiene un procedimiento con un orden de pasos que importa muchísimo, y empieza por algo que no tiene nada que ver con Git. Es la lección 08-05: Mejores Prácticas de Seguridad.

Dominando Git: De Principiante a Avanzado

Módulo 1: Introducción a Git

Módulo 2: Operaciones Básicas de Git

Módulo 3: Ramas y Fusión

Módulo 4: Trabajando con Repositorios Remotos

Módulo 5: Operaciones Avanzadas de Git

Módulo 6: Herramientas y Técnicas de Git

Módulo 7: Estrategias de Colaboración y Flujo de Trabajo

Módulo 8: Mejores Prácticas y Consejos de Git

Módulo 9: Solución de Problemas y Depuración

Módulo 10: Git en el Mundo Real

© Copyright 2026. Todos los derechos reservados