¿Quién lo hizo? Encontrar al culpable con git blame
git blame explicado: interpreta la salida, acota líneas con -L, -w, -M, -C, omite commits masivos y sigue el cambio real de una línea.
git blame anota cada línea de un archivo con el commit que la modificó más recientemente, junto con el autor y la fecha de ese commit.
El commit que señala suele ser el equivocado. Buscas una línea extraña en código que no escribiste tú, y blame te devuelve un commit de “apply prettier” de hace dieciocho meses que tocó 4.000 archivos.
Ese callejón sin salida es el resultado habitual de un git blame a secas, y superarlo es la verdadera habilidad. Este artículo cubre cómo leer la salida por defecto, cómo acotarla y limpiarla de ruido con -L, -w, -M y -C, cómo retroceder por los commits padre hasta llegar al cambio que importa, y dos incorporaciones recientes: --diff-algorithm (Git 2.53 o posterior) y git last-modified (Git 2.52 o posterior).
Puntos clave
- El commit que git blame muestra para una línea es el último que la tocó, que a menudo es un reformateo, un renombrado o un movimiento, en lugar del cambio que le dio su significado.
- Volver a ejecutar blame sobre el padre del commit reportado (
git blame <hash>^ -- file) y repetir es la forma fiable de llegar al cambio original;--ignore-reve--ignore-revs-fileomiten automáticamente los commits ruidosos conocidos. -wignora los espacios en blanco,-Msigue las líneas movidas dentro de un archivo (umbral por defecto de 20 caracteres alfanuméricos) y-Csigue las líneas copiadas desde otros archivos (por defecto 40), y hasta tres flags-Camplían la búsqueda.- Git 2.53 añadió
--diff-algorithma git blame, que aceptapatience,minimal,histogramomyers, siendomyersel valor por defecto. - Git 2.52 añadió el comando experimental
git last-modified, que informa del último commit que tocó cada ruta de un directorio en un único recorrido.
¿Cómo se lee la salida por defecto de git blame?
Cada línea de la salida por defecto de git blame contiene cuatro campos en este orden: el hash abreviado del commit, el nombre del autor, la fecha del autor y el número de línea, seguidos del contenido de la línea. La sección sobre el formato por defecto de la página del manual enumera estos campos; Git acorta el hash a siete dígitos hexadecimales por defecto y deja una columna más libre para el símbolo ^ que marca un commit frontera (los commits más antiguos a los que blame pudo llegar). Las fechas se imprimen en formato ISO salvo que --date o blame.date indiquen lo contrario.
git blame src/router.js
a1b2c3d4 (Jane Doe 2024-03-08 14:22:31 +0100 42) return cache.get(key) ?? fetchRoute(key);
Lee de izquierda a derecha: a1b2c3d4 es el commit, Jane Doe y la marca de tiempo son la identidad del autor de ese commit, 42 es el número de línea en el archivo actual, y todo lo que va después del paréntesis de cierre es la línea en sí.
Lo importante que hay que entender sobre ese hash es lo que no es. No es el commit que introdujo la lógica. Es el commit más reciente cuyo diff tocó la línea, y en una base de código con formateadores, linters y refactorizaciones, eso es con frecuencia un cambio mecánico. Trata el primer resultado de blame como una pista, no como un veredicto.
¿Cómo se limita git blame a un rango de líneas con -L?
git blame -L 40,60 -- src/router.js restringe la anotación a las líneas 40 a 60, y git blame -L :handleRoute -- src/router.js la restringe al cuerpo de la función cuyo nombre coincide con esa expresión regular. Ambas formas están documentadas en la opción -L, que puede indicarse más de una vez.
git blame -L 40,60 -- src/router.js
git blame -L :handleRoute -- src/router.js
La forma :funcname no analiza tu lenguaje. Detecta nombres de funciones del mismo modo que git diff determina qué imprimir en la cabecera de un hunk, y puedes ajustar ese comportamiento por tipo de archivo mediante el atributo diff en gitattributes. Ambos extremos del rango aceptan además patrones /regex/, y el extremo final acepta desplazamientos +N, por lo que -L '/^function handleRoute/,+15' también es válido.
¿Cómo se ignoran los espacios en blanco y el código movido en git blame?
Pasar -w hace que git blame ignore los espacios en blanco al comparar versiones, de modo que un reformateo que solo cambia la indentación deja de reclamar las líneas que tocó. -M detecta las líneas que se desplazaron dentro de un mismo archivo, y -C amplía la búsqueda a las líneas que llegaron desde otros archivos modificados por ese mismo commit; la página del manual fija sus umbrales de coincidencia por defecto en 20 y 40 caracteres alfanuméricos respectivamente.
| Síntoma | Flag |
|---|---|
| Línea atribuida a una reindentación o a una limpieza de espacios finales | -w |
| Línea atribuida al commit que reordenó código dentro del archivo | -M |
| La línea llegó por copia o movimiento desde otro archivo | -C (acumulable) |
| Línea atribuida a un commit masivo conocido | --ignore-rev <hash> |
git blame -w -- src/router.js
git blame -M -- src/router.js
git blame -C -C -C -- src/router.js
Cada -C adicional amplía los archivos en los que git blame busca las líneas copiadas:
-Cbusca en los demás archivos que ese mismo commit modificó.-C -Cbusca además en los archivos tocados por el commit que añadió este archivo por primera vez.-C -C -Clo amplía una vez más, a archivos de cualquier commit.
Si varios flags -C llevan un umbral numérico, gana el último. Un renombrado de archivo completo no necesita ningún flag: blame sigue rastreando las líneas a través de él por sí solo, y actualmente Git no ofrece ninguna forma de desactivar ese comportamiento.
¿Cómo se encuentra el commit anterior a un reformateo masivo?
Para superar un commit mecánico, vuelve a ejecutar blame sobre el padre de ese commit, git blame <hash>^ -- src/router.js, y repite hasta que el commit mostrado sea uno que realmente haya cambiado el comportamiento de la línea. El sufijo ^ es la sintaxis estándar de gitrevisions para el primer padre, así que blame parte del estado del archivo justo antes de que aterrizara el commit ruidoso.
- Ejecuta
git blame -L 40,60 -- src/router.jsy anota el hash de la línea que te interesa. - Revisa el commit con
git show --stat <hash>. Si es un reformateo, un renombrado o un movimiento, continúa. - Ejecuta
git blame -n <hash>^ -L 40,60 -- src/router.js. El flag-nimprime el número de cada línea en el commit original, lo cual importa porque los números de línea se desplazan entre revisiones y puede que necesites reajustar-Len la siguiente pasada. - Repite desde el paso 2 hasta que el commit mostrado cambie lo que hace la línea.
git blame -n a1b2c3d4^ -L 40,60 -- src/router.js
Cuando un repositorio tiene commits ruidosos conocidos, sáltate el recorrido manual. --ignore-rev <hash> indica a git blame que atribuya las líneas más allá de un commit determinado, y --ignore-revs-file hace lo mismo con todo un archivo de hashes, escritos completos, uno por línea. Configura blame.markIgnoredLines para marcar con ? las líneas reasignadas y blame.markUnblamableLines para marcar con * las líneas que no pudieron reasignarse.
git blame --ignore-rev a1b2c3d4 -- src/router.js
git blame --ignore-revs-file .git-blame-ignore-revs -- src/router.js
git config blame.markIgnoredLines true
Cómo versionar esa lista como .git-blame-ignore-revs y apuntar blame.ignoreRevsFile hacia ella se explica en 5 Git Dotfiles Every Developer Should Know.
Probar un algoritmo de diff distinto (Git 2.53 o posterior)
Git 2.53 añadió --diff-algorithm a git blame, que acepta patience, minimal, histogram o myers (con default como alias de myers), siendo myers el valor por defecto. La incorporación aparece en las notas de la versión de Git 2.53, y los valores aceptados se enumeran en la opción —diff-algorithm de la página del manual.
Blame decide qué líneas del padre se corresponden con qué líneas del hijo haciendo un diff entre ambas versiones, y algoritmos distintos emparejan las líneas de forma distinta. Cuando un commit intercala líneas modificadas y sin modificar, como suelen hacer los reformateos, un algoritmo puede atribuir una línea al reformateo mientras que otro la atribuye al commit que la escribió originalmente.
git blame -L 40,60 -- src/router.js
git blame -L 40,60 --diff-algorithm=patience -- src/router.js
No hay ningún algoritmo documentado como más correcto que otro. Si la atribución por defecto parece poco plausible, ejecutar el mismo comando con patience o histogram cuesta una invocación extra y te da una segunda opinión con la que comparar.
Preguntar por un directorio con git last-modified
Git 2.52 añadió git last-modified, que informa del commit que modificó por última vez cada ruta de un directorio en un único recorrido del historial, en lugar de un git log -1 por archivo; el comando está marcado como experimental y su comportamiento puede cambiar. La página del manual de git-last-modified indica el estado experimental en su línea NAME y muestra el formato de salida como <oid> TAB <path>, una línea por ruta, con un identificador de objeto completo y sin autor, fecha ni asunto.
git last-modified -r -- src/
Sin -r (o un --max-depth distinto de cero) solo obtienes las entradas que coinciden con el propio pathspec, sin descender a los subdirectorios que haya por debajo. Los renombrados y los cambios de modo cuentan como modificaciones. El bucle por archivo al que sustituye recorre los mismos commits una vez por cada archivo; last-modified los recorre una sola vez. Responde a “qué ha cambiado recientemente en este módulo”, una pregunta distinta de “por qué existe esta línea”, y conviene recurrir a él antes de empezar a hacer blame de archivos individuales.
Blame es una pregunta, no un veredicto
La salida de git blame nombra a la última persona que tocó una línea, y ese nombre casi nunca es la respuesta que necesitas. Ejecuta blame con -L para enfocar, -w y -M/-C para eliminar el ruido mecánico, y luego retrocede por los padres (o mantén un archivo de exclusiones) hasta que el commit mostrado lleve un mensaje que explique la línea. Una vez que tengas ese commit, git show <hash> te da el diff y el razonamiento, que es el objetivo del ejercicio: entender por qué está ahí el código, para poder cambiarlo sin repetir el incidente que lo puso ahí en primer lugar.
Preguntas frecuentes
¿Cómo averiguo quién eliminó una línea, si git blame solo muestra las líneas que todavía existen?
git blame no te dice nada sobre las líneas que fueron eliminadas o sobrescritas, como señala su página del manual. Usa en su lugar el pickaxe: git log -S'some text' -- src/router.js lista todos los commits que añadieron o eliminaron esa cadena, y añadir -p muestra la propia eliminación. Como alternativa, git blame --reverse a1b2c3d..HEAD -- src/router.js recorre el historial hacia adelante desde ese commit y nombra la revisión más reciente en la que cada línea seguía existiendo.
¿Por qué git blame muestra 00000000 y 'Not Committed Yet' en algunas líneas?
Esas líneas contienen cambios sin confirmar. Sin un argumento de revisión, git blame anota la copia del archivo en el árbol de trabajo, así que cualquier línea que difiera de HEAD recibe un hash de solo ceros y 'Not Committed Yet' en lugar del nombre del autor. Confirma o guarda el cambio con stash, o ejecuta git blame HEAD -- src/router.js para anotar la versión confirmada e ignorar por completo las ediciones locales.
¿La vista de blame de GitHub respeta un archivo .git-blame-ignore-revs?
Sí. GitHub aplica automáticamente un archivo llamado .git-blame-ignore-revs situado en la raíz del repositorio a su vista de blame, usando el mismo mecanismo --ignore-revs-file que la línea de comandos, y muestra un aviso de 'Ignoring revisions' cuando lo hace. Las líneas que no pueden reatribuirse a un commit anterior siguen mostrando el commit ignorado. El archivo no configura el git local; cada desarrollador todavía tiene que ejecutar git config blame.ignoreRevsFile .git-blame-ignore-revs.
¿Cuál es la diferencia entre git blame y git log -L?
git blame informa de un commit por línea: el commit más reciente que la tocó en una única versión del archivo. git log -L 40,60:src/router.js, en cambio, rastrea las líneas 40 a 60 a lo largo del historial e imprime todos los commits que las modificaron, cada uno con el diff de ese rango, del más reciente al más antiguo. Usa blame para identificar rápidamente a un sospechoso y log -L para observar cómo evolucionan las líneas. Ambos aceptan la forma :funcname, como en git log -L :handleRoute:src/router.js.