La lección anterior resolvió qué ficheros no deben entrar en el repositorio. Esta trata del problema complementario y menos conocido: los ficheros que sí 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
- Qué es
.gitattributesy en qué se diferencia de.gitignore - Sintaxis y precedencia
- El problema de los finales de línea
text,eolytext=auto.gitattributesfrente acore.autocrlf- Renormalizar un proyecto que ya tiene el problema
- Marcar binarios con
-textybinary - Diffs personalizados:
diff=<driver> - Que
@@muestre el nombre de la función textconv: ver el texto de ficheros binarios- Drivers de fusión y el caso
merge=union export-ignoreygit archive- Otros atributos:
filter,linguist-*,export-subst - Tabla resumen
- Qué es
.gitattributes y en qué se diferencia de .gitignore
.gitattributes y en qué se diferencia de .gitignoreLos 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:
*.js text eol=lf
*.png binary
*.md diff=markdown merge=union
pruebas/ export-ignore
*.pdf -text diff=pdfLos 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 |
- 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 |
Sí | 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:
# 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 -agit 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.
- 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 diffmuestra el fichero entero modificado sin cambio visible.git statusmarca como modificados ficheros que no has tocado.- Conflictos de fusión en cada línea de un fichero que solo tocó una persona.
git blameatribuye 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.
text, eol y text=auto
text, eol y text=autoEl atributo text
| Declaración | Al confirmar (add) |
Al extraer (checkout) |
|---|---|---|
text |
Convierte CRLF → LF |
Convierte LF → según core.eol (por defecto, el nativo del sistema) |
text=auto |
Convierte CRLF → LF 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=lfno significa "guarda LF en el repositorio": en el repositorio siempre hayLFcuando el fichero estext.eolcontrola qué se escribe en tu disco al extraer.text=autodecide 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=autoY 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 binaryLas dos secciones del medio son las que evitan fallos reales:
*.sh text eol=lf: un script de shell conCRLFno arranca en Linux. El intérprete busca/bin/bashy encuentra/bin/bash\r. Con esta regla, aunque Carla lo edite en Windows, en su disco tendráLFy funcionará en el contenedor.*.bat text eol=crlf: los.batantiguos de Windows necesitanCRLF. Aunque Ana los edite en Ubuntu, en el disco de Carla llegarán conCRLF.
.gitattributes frente a core.autocrlf
.gitattributes frente a core.autocrlfEn 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 |
CRLF → LF |
LF → CRLF |
Windows |
input |
CRLF → LF |
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 inputFunciona, 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 | Sí |
| ¿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:
.gitattributespara la política del proyecto;core.autocrlfcomo red de seguridad personal en repositorios que no lo tengan. Si tienes.gitattributes,core.autocrlfsobra.
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
- 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:
- Avisa con antelación y fija una hora.
- Que todo el mundo fusione o cierre sus ramas antes de esa hora.
- Una persona hace la renormalización en una PR propia, sin nada más dentro.
- Se fusiona en cuanto está aprobada, sin dejarla abierta días.
- Todo el mundo hace
pully, si tenía ramas vivas, las rebasa sobre el commit de renormalización. - 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:
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.
- Marcar binarios con
-text y binary
-text y binaryUn 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.
binary es un macro-atributo: es exactamente equivalente a escribir
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.pngCuá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:
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:
Git guardará el contenido en UTF-8 dentro del repositorio (donde los diffs funcionan) y lo escribirá en UTF-16LE en tu disco.
- Diffs personalizados:
diff=<driver>
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=jsonLa 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.
- Que
@@ muestre el nombre de la función
@@ muestre el nombre de la funciónAquí 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:
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).
# 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.
textconv: ver el texto de ficheros binarios
textconv: ver el texto de ficheros binariosHay 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:
# 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 trueCada 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:
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.
- Drivers de fusión y el caso
merge=union
merge=unionIgual 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:
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
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=unionnunca 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":
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.
export-ignore y git archive
export-ignore y git archivegit 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 HEADEl 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-ignoreComprueba el resultado antes de publicar:
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:
Al hacer git archive, el fichero del paquete sale con los valores rellenos:
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.
- Otros atributos:
filter, linguist-*, export-subst
filter, linguist-*, export-substfilter: 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 -textEsas 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):
git config --local filter.quitar-espacios-finales.clean "sed 's/[[:space:]]*$//'"
git config --local filter.quitar-espacios-finales.smudge catCuidado 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-generatedlinguist-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.
- 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-ignoreErrores 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
- Crea un repositorio con un
estilos.cssde 10 líneas, guardado conLF, y confírmalo sin.gitattributes. - Simula el guardado desde Windows convirtiendo el fichero a
CRLF:sed -i 's/$/\r/' estilos.css - Ejecuta
git diff --staty comprueba que las 10 líneas aparecen modificadas. - Comprueba el estado real con
git ls-files --eol. - Añade un
.gitattributescon*.css text eol=lf, confírmalo, y demuestra que el diff sigue igual de roto hasta que renormalizas. - Renormaliza correctamente y verifica con
git ls-files --eolque el índice queda coni/lf.
Ejercicio 2: diffs con contexto de función
- Crea un
app.jscon tres funciones de unas cinco líneas cada una y confírmalo. - Modifica una línea dentro de la segunda función y ejecuta
git diff. Observa la línea@@. - Añade
*.js diff=javascriptal.gitattributesy repite elgit diff. Compara. - Prueba
git log -L :nombreDeLaFuncion:app.jsy explica qué hace. - Añade un
*.pdf diff=pdfcon untextconv(usapdftotextsi lo tienes, o simula uno con un script propio que convierta un formato inventado a texto) y demuestra quegit diffmuestra el contenido.
Ejercicio 3: merge=union y export-ignore
- Crea un repositorio con un
CHANGELOG.mdque tenga una cabecera y una entrada. - Crea dos ramas y añade en cada una una entrada distinta en la misma posición, al principio de la lista.
- Fusiona la segunda en la primera y comprueba el conflicto.
- Aborta la fusión, añade
CHANGELOG.md merge=unional.gitattributes, confírmalo y repite la fusión. Verifica el resultado. - Explica por qué esta misma configuración sobre
app.jssería peligrosa, con un ejemplo concreto de código roto. - Añade
pruebas/ export-ignorey.gitattributes export-ignore, y demuestra congit archive --format=tar HEAD | tar -tque 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"Diez líneas modificadas sin haber cambiado nada visible. Exactamente el problema de Carla.
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 --statSigue igual. El atributo se aplicará a partir de ahora, pero el contenido del disco sigue con CRLF y Git sigue viendo diferencia.
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Í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.
Git ha añadido la firma de la función. Comprobación de que el atributo se aplica:
# 4. Historial de una función
git add . && git commit -m "refactor(filtros): compara etiquetas sin distinguir mayúsculas"
git log -L :coincideConFiltro:app.jsMuestra 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.invdiff --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 2Git 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"## 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 -tNi pruebas/ ni .gitattributes aparecen: están versionados, pero fuera del paquete.
Conclusión
Lo esencial de esta lección:
.gitignoredecide qué versionar;.gitattributesdecide cómo tratar lo versionado. Actúa encheckout,commit,diff,mergeyarchive, y su diagnóstico esgit check-attr -a.- El problema de los finales de línea —el de Carla— se resuelve con una convención:
LFen el repositorio, lo nativo en el disco. Se declara context,text=autoyeol=lf/eol=crlf, yeolcontrola el disco, no el repositorio. .gitattributesgana acore.autocrlfy, 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 --eolmuestra el estado real antes y después. binaryes el macro de-text -diff -mergey 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 habilitagit log -L :funcion:fichero.textconvpermite ver como texto el contenido de un binario, solo para visualizar.merge=unionconserva las líneas de ambos lados y elimina los conflictos falsos deCHANGELOG.md. Nunca en código: elimina el conflicto, no lo resuelve.export-ignorecontrola qué contienen los paquetes degit archive—incluidos los que generan las plataformas para cada etiqueta—, yexport-substrellena la versión y el commit en el paquete.filter=lfses la puerta de entrada a Git LFS, cuyo mecanismo completo se ve en la lección 10-03; y los atributoslinguist-*, 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
- ¿Qué es Git?
- Instalando Git
- Terminología Básica de Git
- El Modelo de Datos de Git
- Configurando Git
- Configuración Inicial
Módulo 2: Operaciones Básicas de Git
- Creando un Repositorio
- Clonando un Repositorio
- Flujo de Trabajo Básico de Git
- Preparando y Confirmando Cambios
- Inspeccionando Cambios con git diff
- Visualizando el Historial de Confirmaciones
Módulo 3: Ramas y Fusión
- Entendiendo las Ramas
- Creando y Cambiando Ramas
- Fusionando Ramas
- Estrategias de Fusión
- Resolviendo Conflictos de Fusión
- Gestión de Ramas
Módulo 4: Trabajando con Repositorios Remotos
- Entendiendo los Repositorios Remotos
- Agregando un Repositorio Remoto
- Autenticación con Repositorios Remotos
- Obteniendo y Extrayendo Cambios
- Enviando Cambios
- Rastreando Ramas
Módulo 5: Operaciones Avanzadas de Git
- Rebase
- Rebase Interactivo
- Cherry-Picking de Confirmaciones
- Guardando Cambios Temporales
- Etiquetando Confirmaciones
- Revirtiendo Confirmaciones
Módulo 6: Herramientas y Técnicas de Git
- Usando Git Hooks
- Git Bisect
- Git Blame
- Git Log y Alias
- Submódulos de Git
- Múltiples Copias de Trabajo con git worktree
Módulo 7: Estrategias de Colaboración y Flujo de Trabajo
- Forks y Pull Requests
- Revisiones de Código con Git
- Flujo de Trabajo Git Flow
- GitHub Flow
- Trunk Based Development
- Integración Continua con Git
Módulo 8: Mejores Prácticas y Consejos de Git
- Escribiendo Buenos Mensajes de Confirmación
- Manteniendo un Historial Limpio
- Ignorando Archivos con .gitignore
- Atributos de Fichero con .gitattributes
- Mejores Prácticas de Seguridad
- Consejos de Rendimiento
Módulo 9: Solución de Problemas y Depuración
- Problemas Comunes de Git
- Deshaciendo Cambios
- Resolviendo Divergencias con el Remoto
- Recuperando Confirmaciones Perdidas
- Tratando con Repositorios Corruptos
- Técnicas Avanzadas de Depuración
