Con git diff staged ves el commit, no tu directorio
El diff que revisas antes de commitear y el contenido que acaba dentro del commit pueden ser dos cosas distintas. No es trivia de Git: es lo que explica que un .env aparezca en el historial compartido después de una sesión en la que revisaste los cambios uno por uno.
Cuando editas tú solo, el desfase entre índice y directorio dura segundos. Cuando un coding agent toca diez archivos, lanza un formateador y reintenta un paso, ese desfase es el estado normal del repo.
Qué compara exactamente cada forma de git diff
git diff sin argumentos compara tu directorio de trabajo con el índice, y git diff --staged compara el índice con HEAD, es decir, el contenido exacto que entrará en el próximo commit. La documentación de git-diff lo dice de forma literal: la forma sin argumentos muestra los cambios hechos respecto al índice (el área de preparación del próximo commit), y la forma con --cached muestra los cambios preparados respecto al commit indicado, que por defecto es HEAD. --staged es sinónimo de --cached.
La clave es que hay tres objetos en juego, no dos: HEAD (el último commit), el índice (la instantánea preparada) y el directorio de trabajo (los bytes que hay en disco). Cada comando compara un par diferente, y elegir mal el par es elegir mal la pregunta.
| Comando | Compara | Pregunta que responde |
|---|---|---|
git diff | Directorio de trabajo ↔ índice | ¿Qué cambios tengo sin preparar todavía? |
git diff --staged (--cached) | Índice ↔ HEAD | ¿Qué va a entrar en el commit si lo hago ahora? |
git diff HEAD | Directorio de trabajo ↔ HEAD | ¿Cuál es el alcance total del turno, preparado o no? |
git diff --staged main | Índice ↔ otra rama o commit | ¿Qué llevo acumulado desde que salí de main? |
git status --short | Los dos pares a la vez | ¿En qué estado está cada archivo? |
Un archivo con dos cambios distintos al mismo tiempo
La secuencia siguiente deja un mismo archivo con una versión en el índice y otra en disco, que es justo el escenario que engaña. Es el comportamiento que describen git-diff y git-status, así que puedes reproducirlo en cualquier repo de pruebas:
# Prepara una versión del archivo y después sigue editándolo en disco.
printf 'v2-staged\n' > app.py && git add app.py
printf 'v3-worktree\n' > app.py
printf 'SECRET=abc\n' > .env && git add .env
git status --shortLa salida de git status --short es A .env y MM app.py. A partir de ahí, git diff muestra solo v2-staged → v3-worktree (lo que falta por preparar), git diff --staged muestra v1 → v2-staged más el .env completo, y git diff HEAD muestra v1 → v3-worktree. Tres salidas distintas del mismo repo, y solo la segunda describe el commit.
La columna que casi nadie lee en git status
En el formato corto de git-status hay dos columnas de estado por archivo: la primera (X) es el estado del índice respecto a HEAD, la segunda (Y) es el estado del directorio de trabajo respecto al índice. Leer solo la primera es la versión rápida de equivocarse.
M(M y espacio): modificado y preparado. Lo que ves con--stagedes lo definitivo.M(espacio y M): modificado sin preparar. No entra en el commit.MM: preparado y vuelto a modificar después. Señal de formateador, de hook o de un agente que siguió editando tras elgit add.A: archivo nuevo en el índice. Aquí es donde aparecen los secretos y los artefactos generados.??: sin seguimiento. Invisible para los dos diffs.
MM es la marca más directa de que el diff que acabas de leer ya ha caducado, pero no la única: un A que aparece después de tu revisión también cambia lo que entra en el commit, y lo hace sin modificar ninguna línea de los archivos que sí miraste.
Nombrar archivos en git commit se salta el índice
Este es el caso que rompe la intuición de más gente. Si pasas rutas a git commit, Git ignora lo que tenías preparado para esas rutas y commitea el contenido del disco. La documentación de git-commit lo enumera como una de las formas de construir un commit: listando archivos como argumentos, el commit ignora los cambios preparados en el índice y registra el contenido actual de esos archivos.
# El commit se lleva v3-worktree, no la versión v2-staged que habías revisado.
git commit -m "fix" app.py
git show HEAD:app.py # v3-worktree
git status --short # A .env (sigue preparado, esperando el siguiente commit)Dos consecuencias, ambas documentadas. La primera: revisaste una versión y publicaste otra. La segunda: lo que no nombraste no se pierde, se queda preparado y entra en el commit siguiente sin que nadie lo vuelva a mirar. Ese .env no desaparece del índice por haber hecho un commit parcial.
Regla de decisión: si vas a revisar antes de publicar, prepara con git add y commitea sin pathspec. Usa git commit <ruta> solo cuando quieras a propósito la versión de disco y no te importe el índice.
Por qué el índice se desincroniza más con un agente delante
Tres mecánicas muy habituales en un flujo asistido por IA producen desfase entre índice y disco:
- El agente prepara temprano y sigue trabajando. Hace
git add -Apara tener un punto de retorno y después edita dos archivos más. El índice se queda en la foto antigua. - Hooks y formateadores que corren después del add. Black, Prettier o un linter con autofix reescriben el archivo en disco. El índice conserva el código sin formatear, y tu commit publica exactamente eso.
- Reintentos. Cuando un paso falla y el agente lo repite, la mitad de los efectos ya están aplicados. Es el mismo problema de fondo que aparece al reanudar un agente sin repetir efectos ya aplicados, y el índice es uno de los estados que hay que releer, no asumir.
Por eso la revisión del índice encaja bien como criterio de verificación ejecutable en lugar de como buena intención: un comando con salida clara, no un "he mirado el diff". Y encaja igual de bien con dar distinto margen de autonomía según el tipo de cambio: que el agente prepare cambios es barato, que publique sin que nadie compare índice y HEAD no lo es.
Checklist copiable: revisar el índice en 30 segundos
# 1. Estado de las dos columnas: ¿hay algún MM o algún A inesperado?
git status --short
# 2. Alcance de lo preparado, antes de leer el contenido línea a línea.
git diff --staged --stat
# 3. Archivos nuevos que entran al historial (aquí salen secretos y artefactos).
git diff --staged --diff-filter=A --name-only
# 4. Contenido preparado, quitando el ruido generado con pathspec negativo.
git diff --staged -- ':!*.lock' ':!dist' ':!*.snap'
# 5. Lo que se queda fuera: decídelo, no lo dejes al azar.
git diff --statCómo decidir con esas cinco salidas:
- Paso 3 lista algo que no reconoces: abre el archivo antes de commitear. Un
.env, un.pemo un dump de base de datos no se arreglan con un commit de corrección, porque el contenido ya está en el historial. - Paso 1 muestra
MM: vuelve a decidir. Ogit adddel archivo completo, o acepta a conciencia que publicas la versión antigua. - Paso 5 no está vacío: eso es trabajo que se queda fuera del commit. Que sea intencionado, no un descuido.
- Paso 4 sigue midiendo en miles de líneas después de excluir ruido: el commit es demasiado grande para una revisión humana con criterio. Pártelo.
El mismo check dentro de un hook
Un pre-commit corre antes de crear el commit, así que, cuando necesitas fidelidad con lo que va a entrar en el commit, debe mirar el índice y no el directorio de trabajo. Con --staged el hook ve lo mismo que verá el commit:
#!/bin/sh
# Bloquea el commit si entra al índice un archivo nuevo con pinta de secreto.
for f in $(git diff --staged --diff-filter=A --name-only); do
case "$f" in
*.env|*.pem|*id_rsa*) echo "bloqueado: $f no debe entrar al commit"; exit 1 ;;
esac
done
# --check avisa de espacios en blanco problemáticos y sale con código 2 si los hay.
git diff --staged --check || exit 1Dato útil para scripts: git diff --cached --quiet sale con 1 cuando hay algo preparado y con 0 cuando el índice está limpio. Está documentado: según la documentación de git-diff, --quiet implica --exit-code, y --exit-code hace que el programa «salga con 1 si había diferencias y 0 si no las había». Es la forma barata de que un agente detecte si tiene algo que commitear sin parsear texto. Si trabajas en un microservicio Node con Express, añade package-lock.json y dist/ a las exclusiones del paso 4: con lockfiles regenerados, el diff útil se ahoga en miles de líneas.
Casos borde donde la intuición falla
- Rama sin commits. Si
HEADno existe (rama unborn) y no indicas commit,git diff --stagedmuestra todos los cambios preparados, según la documentación de git-diff. No falla ni devuelve vacío: compara contra la nada. - Los untracked son invisibles para los dos diffs. Un archivo nuevo sin
git addno aparece ni engit diffni engit diff --staged. Solo lo ves como??engit status. Congit add -N(intent-to-add) Git registra la ruta sin contenido y entonces el archivo sí sale engit diff, tal como describe la documentación de git-add. git commit -ase salta tu revisión por diseño. Prepara automáticamente todo lo que ya tiene seguimiento, incluidas las rutas marcadas con-N. El commit resultante no es el que revisaste con--staged.- Binarios y archivos generados.
git diff --stagedno te enseña contenido legible de un PNG o de un modelo serializado. Usa--stato--numstatpara confirmar que el tamaño y el número de archivos cuadran con lo que esperabas. - Filtros y finales de línea. El índice guarda el contenido ya pasado por los filtros configurados (clean, normalización de CRLF). Un diff que parece tener cambios fantasma suele ser esto, no un bug del agente.
La versión corta para el día a día: git diff --staged es la única comparación que responde a qué se va a commitear, y deja de ser cierta en el momento en que nombras archivos en git commit o pasas un -a.