git bisect run: encuentra el commit que lo rompió

Caza una regresión entre 64 commits con git bisect run: 8 corridas en vez de 41, exit 125 para commits que no compilan y una lentitud de 1 ms a 613 ms.

Serie: curso de pruebas y depuración 6/6 (avanzado). Para quien sabe que algo funcionaba el mes pasado, ahora está roto, y tiene enfrente un historial de decenas de commits. Al terminar vas a poder convertir una verificación que falla en un script, dejar que git bisect run encuentre el primer commit malo, manejar commits que no compilan y cazar una lentitud de la misma forma.

Este es el escenario: un cliente de tu trabajo freelance te escribe que un descuento del 10% sobre un artículo de 3.33 dólares ahora muestra 2.99 en lugar de 3.00. La versión que entregaste el mes pasado estaba bien. Desde entonces entraron 63 commits, la mayoría inofensivos. Leer cada diff te lleva la tarde; hacer checkout de los commits uno por uno, menos, pero igual es mucho. git bisect hace una búsqueda binaria sobre el historial, así que necesita unas log2(N) verificaciones, y git bisect run las hace por ti con cualquier script que devuelva un código de salida. En el bootcamp seguramente viste Git como una herramienta para guardar historia; aquí lo vas a usar para buscar en ella. Construimos un repositorio desechable con exactamente 64 commits, escondimos tres problemas y medimos cuánto tomó cada búsqueda.

Entorno: Git 2.54.0 (Apple Git-157), Python 3.9.6, /bin/sh, una Mac con Apple Silicon (M1 Pro). Todos los commits usan una identidad de demostración y fechas UTC fijas, así que si corres el generador de abajo con la misma versión de Git deberías obtener los mismos hashes que mostramos. Las rutas están acortadas a ~/demo/…. Clase anterior: pruebas inestables (flaky).

El repositorio de práctica: 64 commits con tres problemas escondidos

El generador crea un cart.py chiquito con dos funciones y luego hace 64 commits. La mayoría solo actualiza version.py y un changelog. Cuatro son especiales:

  • 31 "Show totals with money() helper": agrega from fmt import money, pero fmt.py todavía no existe. Los commits 31 a 34 ni siquiera se pueden importar.
  • 35 "Add fmt.money": agrega el archivo que faltaba.
  • 41 "Simplify discount rounding": el bug. round(x, 2) se vuelve un truncamiento.
  • 52 "Make unique_skus easier to read": una regresión de rendimiento; un set reemplazado por búsquedas en una lista.
#!/bin/sh
# make_repo.sh <dir>: a throwaway repo with 64 commits (macOS sed syntax)
set -eu
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
export GIT_AUTHOR_NAME=demo GIT_AUTHOR_EMAIL=demo@example.com
export GIT_COMMITTER_NAME=demo GIT_COMMITTER_EMAIL=demo@example.com
rm -rf "$1"; mkdir -p "$1"; cd "$1"; git init -q -b main
cat > cart.py <<'PY'
from version import VERSION

def total(prices, discount_pct=0):
    """Sum prices (in dollars) and apply a percentage discount, rounded to cents."""
    subtotal = sum(prices)
    return round(subtotal * (100 - discount_pct) / 100, 2)

def unique_skus(skus):
    """Keep the first occurrence of each SKU, in order."""
    seen, out = set(), []
    for s in skus:
        if s not in seen:
            seen.add(s)
            out.append(s)
    return out
PY
i=1
while [ $i -le 64 ]; do
  echo "VERSION = $i" > version.py
  echo "- change $i" >> CHANGELOG.md
  case $i in
    31) sed -i '' 's/^from version import VERSION$/&\nfrom fmt import money  # added in 31/' cart.py ;;
    35) printf 'def money(x):\n    return f"${x:.2f}"\n' > fmt.py ;;
    41) sed -i '' 's/return round(subtotal \* (100 - discount_pct) \/ 100, 2)/return int(subtotal * (100 - discount_pct)) \/ 100/' cart.py ;;
    52) sed -i '' -e 's/seen, out = set(), \[\]/out = []/' -e 's/if s not in seen:/if s not in out:/' -e '/seen.add(s)/d' cart.py ;;
  esac
  case $i in
    1) msg="Add cart with total() and unique_skus()" ;;  31) msg="Show totals with money() helper" ;;
    35) msg="Add fmt.money" ;;  41) msg="Simplify discount rounding" ;;
    52) msg="Make unique_skus easier to read" ;;  *) msg="Update changelog ($i)" ;;
  esac
  ts=$(( 1793836800 + i * 3600 ))   # 2026-11-05 00:00 UTC + i hours
  git add -A
  GIT_AUTHOR_DATE="@$ts +0000" GIT_COMMITTER_DATE="@$ts +0000" git commit -q -m "$msg"
  i=$((i + 1))
done

GIT_CONFIG_GLOBAL=/dev/null deja fuera del experimento tu propia configuración de Git (hooks, firmas, alias), y las fechas fijas hacen reproducibles los hashes. No practiques bisect en un repositorio que te importe hasta que sepas cómo git bisect reset te regresa a donde estabas.

El script de prueba: el código de salida es toda la interfaz

git bisect run hace checkout de un commit, corre tu comando y lee solamente su código de salida. Nuestra verificación:

#!/bin/sh
# check_total.sh: lives OUTSIDE the repo, so checking out old commits cannot change it.
# exit 0 = good, 1 = bad, 125 = cannot test this commit (skip)
python3 -c "import cart" 2>/dev/null || exit 125
python3 - <<'PY'
import sys, cart
got = cart.total([3.33], discount_pct=10)
sys.exit(0 if got == 3.0 else 1)
PY
Código de salida Qué significa para git bisect run Origen típico
0 bueno la verificación pasó
1 a 127, excepto 125 malo falló una aserción, el runner reportó fallos
125 saltar: este commit no se puede probar no compila, falta una dependencia
126, 127 malo, pero Git 2.54 revisa antes el commit bueno permiso denegado, comando no encontrado (un typo en el script)
128 o más abortar el bisect el script mismo se cayó o lo mataron

De la tabla salen dos reglas. Pon el script fuera del repositorio (o en un archivo sin seguimiento); si no, al hacer checkout de un commit viejo el script cambia o desaparece a media búsqueda. Y decide a propósito qué significa "no se puede probar", para que un problema de compilación devuelva 125 y no 1.

Diagrama de flujo. Empiezas con un commit bueno y uno malo. Git hace checkout de un commit a la mitad del rango que queda y corre tu script. El código de salida 0 lo marca como bueno, de 1 a 127 excepto 125 lo marca como malo, 125 lo salta y 128 o más aborta la corrida. Bueno, malo y salto achican el rango y vuelven al siguiente checkout hasta que queda un solo primer commit malo. Con 126 o 127, Git 2.54 vuelve a probar el commit bueno conocido y se detiene con un error de bogus exit code si también falla ahí.
Qué hace git bisect run con el código de salida de tu script. Verificado con Git 2.54.0.

La búsqueda: 8 corridas, 0.49 segundos

Le dimos a Git el commit más nuevo como malo y el primero como bueno, y le pasamos el script:

$ git bisect start main 0129d59
$ git bisect run ../check_total.sh
Bisecting: 31 revisions left to test after this (roughly 5 steps)
[b8afa63c5781266c73dee632f2e57ff2645db3ec] Update changelog (32)
running '../check_total.sh'
Bisecting: 30 revisions left to test after this (roughly 5 steps)
[22d5c50674ffd3cfd696345963e7b32e8d9e3442] Update changelog (33)
running '../check_total.sh'
Bisecting: 30 revisions left to test after this (roughly 5 steps)
[2faf604004b11123e9c19505a110323dd390ec8f] Update changelog (40)
running '../check_total.sh'
Bisecting: 11 revisions left to test after this (roughly 4 steps)
[6ca4e3b711ecad8482f445fdd802702555ed2990] Make unique_skus easier to read
…
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[497854fdc48159eedda284b046a4363da0bb985b] Simplify discount rounding
running '../check_total.sh'
497854fdc48159eedda284b046a4363da0bb985b is the first bad commit
commit 497854fdc48159eedda284b046a4363da0bb985b
Author: demo <demo@example.com>
Date:   Fri Nov 6 17:00:00 2026 +0000

    Simplify discount rounding
…
bisect found first bad commit

Léelo como una historia. La primera elección de Git, el commit 32, está justo a la mitad, pero cae en el rango que no compila, así que el script salió con 125. El conteo bajó de 31 a 30 y ahí se quedó: un commit saltado solo sale de la lista de candidatos; no parte nada a la mitad. Luego Git eligió el 33 (tampoco compila), se alejó de la zona rota hacia el 40 (bueno), luego el 52 (malo), el 46, el 43, el 42 y el 41 (todos malos). El script corrió 8 veces; dos fueron saltos. Después:

git bisect log > ~/demo/bisect.log   # el registro completo de decisiones, reproducible con git bisect replay
git bisect reset                     # de vuelta a main, donde empezaste

El diff del culpable son dos líneas, round(…, 2) cambió a int(…) / 100: 3.33 × 90 = 299.7, truncado a 299, o sea 2.99 dólares.

Medido contra revisar cada commit. Un bucle que hace checkout de cada commit desde el más viejo y se detiene en el primer código 1 necesitó 41 checkouts y de 2.98 a 3.07 s (3 corridas). git bisect run necesitó 8 corridas y de 0.47 a 0.51 s. Importa más la proporción que los segundos: nuestra verificación tarda unos 60 ms; si la tuya es compilar y probar durante 5 minutos, 41 corridas son más de tres horas y 8 corridas son 40 minutos.

Pasos contra log2. Para comprobar lo de "log2", usamos un segundo script que marca un commit como malo cuando VERSION >= K, e hicimos bisect de cada posición posible del culpable, K = 2 a 64, sin saltos. 62 posiciones necesitaron 6 corridas, una necesitó 5 (promedio 5.98), y las 63 respuestas fueron correctas. log2(64) = 6. Duplicar el historial a 128 commits agregaría un paso, no 64.

Gráfica de barras. Revisar cada commit desde el más viejo: 41 corridas. git bisect run saltando dos commits que no se pueden probar: 8 corridas. git bisect run sin saltos, en promedio sobre las 63 posiciones posibles del culpable: 5.98 corridas; 62 posiciones necesitaron 6 y una necesitó 5. log2 de 64 es 6.
Veces que corrió el script de prueba para encontrar al culpable entre 64 commits (Git 2.54.0, Mac con Apple Silicon (M1 Pro)). Tiempo real: búsqueda lineal 2.98-3.07 s, bisect 0.47-0.51 s (3 corridas cada uno).

Saltar commits que no se pueden probar, y qué pasa si no lo haces

Error común → qué pasa en realidad → arreglo. Quita la línea || exit 125, y un error de import se vuelve un fallo más de Python, código 1. Mismo repositorio, mismo bug:

ModuleNotFoundError: No module named 'fmt'
…
dda1d4a1afa7cc00da8fadaa54ddf6d2fe25b05c is the first bad commit
    Show totals with money() helper

Git culpa con toda seguridad al commit 31, que rompió la compilación pero no tocó el redondeo. El arreglo es la línea del 125: "no puedo juzgar este commit" es distinto de "este commit es malo".

Saltar tiene un límite. Cuando el culpable queda junto a commits saltados, Git ya no puede acotar más. Fingimos que el bug llegó en el commit 33, dentro del rango roto:

There are only 'skip'ped commits left to test.
The first bad commit could be any of:
22d5c50674ffd3cfd696345963e7b32e8d9e3442
b8afa63c5781266c73dee632f2e57ff2645db3ec
611340bd60d10b72a26966fce29c32a0923c4425
dda1d4a1afa7cc00da8fadaa54ddf6d2fe25b05c
14e91fd0044a40f3cf34876f640bdad31c3e041e
We cannot bisect more!
error: bisect run cannot continue any more

Aun así sirve: cinco candidatos en lugar de 63. Para avanzar más, haz que los commits rotos se puedan probar (por ejemplo, que el script cree temporalmente el fmt.py que falta) y vuelve a correr.

Dos trampas de script en las que caímos. Primera: un typo, pyhton3, da código 127 en cada commit. Git 2.54 no culpó al primer commit; volvió a correr el script en el commit bueno y se detuvo: error: bogus exit code 127 for good revision. Versiones anteriores de Git simplemente contaban 127 como malo, así que revisa git help bisect en la tuya. Un código 129 detuvo la corrida con error: bisect run failed: exit code 129 from '../check_129.sh' is < 0 or >= 128. Segunda: un script de Python guardado fuera del repositorio (python3 ../bench_skus.py) falló en todos los commits con ModuleNotFoundError: No module named 'cart', porque Python pone en sys.path la carpeta del script, no la carpeta actual. PYTHONPATH=. python3 ../bench_skus.py lo arregló. Con || exit 125 delante, ese error habría saltado todos los commits; por eso corre siempre tu script a mano en un commit bueno y en uno malo antes del bisect run.

Bisect de una regresión de rendimiento

Las verificaciones de correctitud se convierten fácil en códigos de salida. La velocidad necesita un umbral, y el umbral necesita mediciones primero:

# bench_skus.py: time cart.unique_skus on fixed data, print the best of 5 in ms
import random, time, cart
rng = random.Random(42)
skus = [f"SKU-{rng.randrange(10_000):05d}" for _ in range(20_000)]
best = min(
    (lambda t0: (cart.unique_skus(skus), time.perf_counter() - t0)[1])(time.perf_counter())
    for _ in range(5)
)
print(f"{best * 1000:.2f}")

Diez corridas en el commit 51 dieron de 0.98 a 1.01 ms (mediana 0.99); diez en el commit 52 dieron de 612.65 a 613.58 ms. La búsqueda en la lista vuelve la función O(n²) con 20,000 elementos. Cualquier umbral entre los dos grupos sirve; elegimos 50 ms, lejos de ambos. El script, con los términos personalizados de Git para que la salida diga lo que queremos decir:

#!/bin/sh
# check_speed.sh: fast (exit 0) under LIMIT_MS, slow (exit 1) over it
ms=$(PYTHONPATH=. python3 ../bench_skus.py) || exit 125
echo "unique_skus: $ms ms (limit ${LIMIT_MS:-50})"
python3 -c "import sys; sys.exit(0 if $ms < ${LIMIT_MS:-50} else 1)"
$ git bisect start --term-old=fast --term-new=slow
$ git bisect slow main
$ git bisect fast 0129d59
$ git bisect run ../check_speed.sh
…
unique_skus: 0.99 ms (limit 50)
…
unique_skus: 613.02 ms (limit 50)
…
6ca4e3b711ecad8482f445fdd802702555ed2990 is the first slow commit

8 corridas, 4.06 s, casi todo en los commits lentos. Una regresión real rara vez es de 600 veces; para una lentitud del 10 al 20%, toma el mejor de más repeticiones, deja la máquina tranquila y revisa el umbral a mano en ambos lados antes de confiar en la corrida. Para encontrar dónde se va el tiempo una vez que tienes el commit, mira perfilado en Python y, para C, bucles amigables con la caché y lldb (en inglés).

Las verificaciones flaky hacen mentir a bisect

Bisect asume que la verificación es determinista. Envolvimos la verificación real con un 10% de probabilidad de fallo falso (con semilla por corrida y por commit, para que cada resultado sea reproducible) y corrimos 50 bisects: 46 encontraron el commit 41; 4 culparon a los commits 15, 38, 40 y 40. Un solo "malo" falso en un commit bueno manda la búsqueda a la mitad equivocada, y Git nunca lo vuelve a probar. Un envoltorio que solo marca malo tras 3 fallos seguidos acertó 49 de 50 (una vez todavía culpó al 40). Los reintentos reducen el daño; no lo eliminan. Arregla primero la prueba flaky, con los métodos de la clase 5.

Otras opciones que conviene conocer: git bisect start --first-parent (Git 2.29+) sigue solo los merge commits de la línea principal, útil cuando las ramas de features tienen commits intermedios rotos; git bisect skip <rango> salta a mano un rango que sabes que está roto; y cuando bisect cae en un commit enorme, es señal de hacer commits más pequeños, en lo que rebase y cherry-pick ayudan. Si encuentras al culpable y necesitas deshacerlo con seguridad en una rama compartida, mira git revert y reflog.

Hábitos de post-mortem y la lista de la serie

Encontrar el commit es la mitad de la historia, no el final. Después de una regresión, escribe un post-mortem corto con estos campos, sin culpar a nadie:

Campo Ejemplo de esta clase
Síntoma y primer reporte 10% de descuento sobre 3.33 muestra 2.99
Culpable y cómo se encontró commit 497854f, git bisect run, 8 corridas, bisect.log adjunto
Por qué las pruebas no lo atraparon ninguna prueba con un valor cuyo tercer decimal fuera 5 o más
Prueba de regresión agregada total([3.33], 10) == 3.0, ahora permanente en la suite
Qué abarata la próxima vez el script de verificación guardado en tools/, commits pequeños que compilan

La última fila es la que todos se saltan. Guardar el script de bisect en el repositorio (en una ruta que ningún commit viejo tenga, o copiándolo afuera antes de usarlo) hace que la siguiente búsqueda empiece en un minuto. Un cron que corra la verificación cada noche con buenos logs, como en scripts de shell robustos con logging (en inglés), atrapa la regresión el mismo día que entra. Con clientes freelance, ese post-mortem corto es además la mejor forma de explicar qué pasó sin perder su confianza.

Lista de la serie de pruebas y depuración. Antes de pedir ayuda, o antes de cerrar un bug:

  1. Lee el error completo y encuentra el primer frame de tu propio código (clase 1).
  2. Reprodúcelo con el programa o la entrada más pequeña posible, y usa logging o un depurador, no suposiciones (clase 2).
  3. Escribe una prueba que falle y muestre el bug, y luego arregla el código hasta que pase (clase 3).
  4. Reemplaza dependencias lentas o externas con fakes, pero conserva al menos una prueba que use la real (clase 4).
  5. Si una prueba es flaky, mide su tasa, encuentra la entrada oculta y vuelve a medir (clase 5).
  6. Si antes funcionaba, convierte la verificación en un código de salida y deja que git bisect run encuentre el commit. Luego escribe el post-mortem.

Límites honestos y notas de versión

  • Corrido solo con Git 2.54.0 (Apple Git-157) y Python 3.9.6. git bisect run existe desde hace muchísimo; --term-old/--term-new llegaron en Git 2.7 y --first-parent en Git 2.29. La doble revisión de 126/127 es lo que observamos en 2.54; no probamos versiones anteriores.
  • Las líneas sed -i '' del generador son sintaxis de macOS/BSD. Con GNU sed, usa sed -i sin la cadena vacía (no se ejecutó aquí).
  • Qué commit elige Git después de un salto depende de su elección interna; si tu historial es distinto, la secuencia puede cambiar, pero la respuesta no debería.
  • Los tiempos son de una sola máquina. El costo por corrida aquí es casi todo arranque de procesos, así que el número para recordar es la proporción entre 41 y 8 corridas.
  • Para ver dónde encaja esta serie, la ruta de estudio para tu primer trabajo.

Ejercicios

  1. Tu script de verificación corre make test. En algunos commits viejos, make falla con "No rule to make target". ¿Qué código de salida debe devolver el script en ese caso, y cómo lo distingues de pruebas que fallan?
  2. Hay 1,000 commits entre la última versión buena y hoy. ¿Más o menos cuántas verificaciones va a necesitar git bisect run, y qué haría ese número mucho más grande?

Respuestas

  1. Corre primero solo la compilación y haz exit 125 si falla; solo después corre las pruebas y devuelve su código de salida (o exit 1 ante cualquier fallo). Separar "no compila" de "fallan las pruebas" es justo lo que evita el culpable equivocado de arriba.
  2. Unas log2(1000) ≈ 10. Muchos commits saltados (cada uno agrega corridas sin acotar) y una verificación flaky (que puede mandar la búsqueda a la mitad equivocada) lo hacen más grande o equivocado.

Anterior: Curso de pruebas y depuración 5: pruebas inestables · Esta es la última clase de la serie; vuelve a empezar desde la clase 1, leer mensajes de error y stack traces.

Preguntas frecuentes

¿Qué códigos de salida espera git bisect run de mi script?

0 significa que el commit es bueno, de 1 a 127 excepto 125 significa malo, 125 significa que este commit no se puede probar y hay que saltarlo, y 128 o más aborta el bisect. En Git 2.54.0, un código 126 o 127 (comando no encontrado) hace que Git vuelva a correr el script en el commit bueno conocido; si también falla ahí, Git se detiene con 'error: bogus exit code 127 for good revision' en lugar de culpar a un commit equivocado.

¿Cuántos pasos necesita git bisect?

Más o menos log2 del número de commits del rango. Para 64 commits son 6. Hicimos bisect de las 63 posiciones posibles del culpable en un repositorio de 64 commits: 62 necesitaron 6 corridas del script, una necesitó 5, y todas las respuestas fueron correctas. Los commits que saltas con el código 125 agregan corridas; nuestra búsqueda real con dos commits que no compilaban tomó 8.

¿Puede git bisect encontrar una regresión de rendimiento?

Sí. Escribe un script que mida el tiempo del código, salga con 0 si está bajo un umbral y con 1 si lo pasa, y empieza con git bisect start --term-old=fast --term-new=slow. Pon el umbral entre los dos niveles medidos: los nuestros eran de 0.98 a 1.01 ms antes y de 612.65 a 613.58 ms después, así que 50 ms era seguro. Git reportó 'is the first slow commit' tras 8 corridas en 4.06 s.

  • #pruebas
  • #avanzado
  • #git
  • #git bisect
  • #depuración
  • #regresiones
  • #rendimiento

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑