Carla abre una incidencia con un título incómodo: "El botón de borrar tarea no borra nada". Lo reproduce en tres navegadores. Pulsas la papelera, la tarea parpadea y sigue ahí.

Lo desconcertante es que funcionaba. En la versión v1.0.0, que el equipo etiquetó hace dos meses (lección 05-05), el borrado funcionaba perfectamente: hay una demo grabada que lo demuestra. Entre aquella etiqueta y main hay 214 commits.

Las opciones evidentes son malas. Leerse los 214 diffs es un día de trabajo y una garantía de no ver nada. Buscar por palabras clave con git log -S "borrar" (lección 02-06) da veintitantos candidatos, y ninguno parece culpable. Y preguntar en el chat del equipo solo produce teorías.

Pero hay una propiedad del problema que lo cambia todo: el historial está ordenado, y en algún punto de esa secuencia el comportamiento pasó de "funciona" a "no funciona". Eso es exactamente el escenario de una búsqueda binaria, y Git trae una implementación integrada: git bisect. Con ella, esos 214 commits se reducen a ocho pruebas.

Contenido

  1. La idea: búsqueda binaria sobre el historial
  2. Cuántos pasos hacen falta de verdad
  3. Una sesión manual completa
  4. git bisect skip: commits que no se pueden probar
  5. git bisect log y replay: no perder el trabajo
  6. Automatizar con git bisect run
  7. El script de prueba para gestor-tareas
  8. Los códigos de salida, con detalle
  9. --first-parent: saltarse el interior de las fusiones
  10. git bisect terms: cuando no es "bueno/malo"
  11. Qué hace que un historial sea bisecable

  1. La idea: búsqueda binaria sobre el historial

La premisa de bisect es una sola:

Existe un commit concreto a partir del cual el comportamiento cambió. Antes de él, bien; desde él, mal.

Si eso se cumple, no hace falta probar los commits uno a uno. Basta con probar el del medio:

  • Si el del medio está mal, el culpable está en la primera mitad. La segunda mitad se descarta entera.
  • Si el del medio está bien, el culpable está en la segunda mitad. La primera se descarta entera.

Y se repite sobre la mitad que queda. Cada prueba elimina la mitad de los candidatos.

flowchart TD
    A["214 candidatos<br/>v1.0.0 (bien) ... main (mal)"] --> B["Probar el commit del medio"]
    B -- "funciona → good" --> C["107 candidatos<br/>(mitad superior)"]
    B -- "no funciona → bad" --> D["107 candidatos<br/>(mitad inferior)"]
    C --> E["Probar el nuevo medio"]
    D --> E
    E --> F["54 → 27 → 13 → 7 → 3 → 2 → 1"]
    F --> G["Commit culpable identificado"]

Git hace todo el trabajo de logística: calcula qué commit probar, hace el checkout por ti (en detached HEAD, lección 03-02), lleva la cuenta de los descartados y al final te dice el hash exacto. Tú solo respondes una pregunta binaria en cada paso: ¿funciona o no?

  1. Cuántos pasos hacen falta de verdad

El número de pruebas es el logaritmo en base 2 del número de candidatos, redondeado hacia arriba. La diferencia con la búsqueda lineal es brutal y merece verse en una tabla:

Commits entre bueno y malo Pruebas con bisect (log₂) Pruebas una a una (peor caso)
8 3 8
32 5 32
100 7 100
214 8 214
1 000 10 1 000
10 000 14 10 000
1 000 000 20 1 000 000

Ese es el titular: duplicar el tamaño del historial añade una sola prueba. Un repositorio con un millón de commits se biseca en veinte pasos. Es la razón por la que bisect sigue siendo útil por muy grande que sea el proyecto.

El propio Git te lo dice al empezar:

Bisecting: 106 revisions left to test after this (roughly 7 steps)

"106 revisiones por probar, aproximadamente 7 pasos". No es una estimación optimista: es matemática.

  1. Una sesión manual completa

Carla empieza. Lo primero, dejar el árbol de trabajo limpio: bisect va a hacer muchos checkout, y no puede si hay cambios sin confirmar. Si los tiene, git stash (lección 05-04).

cd ~/proyectos/gestor-tareas
git status --short          # debe estar vacío
git switch main
git pull

Paso 1: iniciar la sesión.

git bisect start

No responde nada. Git ha entrado en modo bisección: ha creado referencias internas en .git/refs/bisect/ y un fichero .git/BISECT_LOG.

Paso 2: marcar los dos extremos.

git bisect bad                 # el commit actual (main) está mal
git bisect good v1.0.0         # la etiqueta v1.0.0 estaba bien
Bisecting: 106 revisions left to test after this (roughly 7 steps)
[9f3c7a1e5b2d8c4f6a0e3b7d9c1f5a8e2b4d6c9f] Extraer la validación del formulario a su función

Fíjate en lo que ha pasado: Git ha hecho checkout del commit intermedio. El árbol de trabajo de Carla es ahora el del commit 9f3c7a1, y HEAD está desacoplado:

git status
HEAD detached at 9f3c7a1
You are currently bisecting, started from main.
  (use "git bisect reset" to get back to the original branch)

nothing to commit, working tree clean

Paso 3: el bucle. Carla abre index.html en el navegador, crea una tarea, pulsa la papelera:

python3 -m http.server 8000    # servir el proyecto
# ... probar en el navegador ...

Funciona. Lo marca:

git bisect good
Bisecting: 53 revisions left to test after this (roughly 6 steps)
[4e8b2c9d7f1a3e5c6b0d8f2a4c7e9b1d3f5a7c8e] Añadir el filtro por estado de la tarea

De 106 a 53 de un golpe. Y así sucesivamente:

# Prueba en 4e8b2c9: NO funciona
git bisect bad
Bisecting: 26 revisions left to test after this (roughly 5 steps)
[7a1f5c3e9b2d6a8c4f0e7b3d5a9c1e6f2b8d4a7c] Reescribir el renderizado del listado
# Prueba: NO funciona
git bisect bad
Bisecting: 12 revisions left to test after this (roughly 4 steps)
[2c6e9a4f8b1d7c3e5a0f9b2d6c8e4a1f7b3d5c9e] Extraer los estilos del listado a estilos.css

Carla continúa: good, bad, good, bad... Cinco pruebas más tarde:

git bisect bad
8d4f2a7c1e6b9d3f5a8c0e2b4d7f9a1c3e5b8d6f is the first bad commit
commit 8d4f2a7c1e6b9d3f5a8c0e2b4d7f9a1c3e5b8d6f
Author: Bruno Salas <[email protected]>
Date:   Mon Jun 22 11:14:38 2026 +0200

    Delegar los eventos del listado en el contenedor

 app.js | 22 ++++++++++------------
 1 file changed, 10 insertions(+), 12 deletions(-)

Ocho pruebas. 214 commits. Un culpable con nombre, fecha y diff.

Paso 4: mirar el diff.

git show 8d4f2a7
diff --git a/app.js b/app.js
--- a/app.js
+++ b/app.js
@@ -78,18 +78,16 @@
-  document.querySelectorAll('.borrar').forEach((boton) => {
-    boton.addEventListener('click', (e) => {
-      borrarTarea(e.target.dataset.id);
-    });
-  });
+  lista.addEventListener('click', (e) => {
+    if (e.target.classList.contains('borrar')) {
+      borrarTarea(e.target.dataset.id);
+    }
+  });

Ahí está. Bruno cambió los escuchadores individuales por delegación de eventos en el contenedor, que es una mejora legítima. Pero el botón de borrar contiene un <svg> con el icono de papelera, así que al pulsar, e.target es el <svg>, no el botón: classList.contains('borrar') da false y no pasa nada. Con e.target.closest('.borrar') funcionaría.

Sin bisect, encontrar eso habría llevado horas. Con él, ocho pruebas y un diff de doce líneas.

Paso 5: salir. Y este paso no es opcional:

git bisect reset
Previous HEAD position was 8d4f2a7 Delegar los eventos del listado en el contenedor
Switched to branch 'main'

reset limpia el estado de bisección y devuelve a HEAD a donde estaba al empezar. Si lo olvidas, te quedas en detached HEAD sobre un commit antiguo, y el siguiente git status será una sorpresa desagradable.

Resumen de los comandos de la sesión:

Comando Qué hace
git bisect start Inicia la sesión
git bisect bad [<sha>] Marca un commit como defectuoso (por defecto, HEAD)
git bisect good [<sha>] Marca un commit como correcto
git bisect start <malo> <bueno> Atajo: inicia y marca los dos extremos de una vez
git bisect skip "No puedo probar este" (apartado 4)
git bisect log Muestra la sesión hasta ahora
git bisect replay <fichero> Reproduce una sesión guardada
git bisect visualize (o view) Abre gitk/log con los candidatos restantes
git bisect run <comando> Automatiza toda la sesión (apartado 6)
git bisect reset [<ref>] Termina y vuelve al punto de partida

El atajo del inicio ahorra dos comandos y es el que se usa en la práctica:

git bisect start main v1.0.0     # <malo> primero, <bueno> después

  1. git bisect skip: commits que no se pueden probar

En la sexta prueba, Carla se encuentra esto:

python3 -m http.server 8000
# La página aparece completamente en blanco. La consola del navegador dice:
# Uncaught SyntaxError: Unexpected token '}' en app.js:96

Ese commit está roto por otro motivo. No es que el borrado no funcione: es que la aplicación entera no arranca. Marcarlo bad sería mentirle a bisect y podría llevarlo por el camino equivocado; marcarlo good, peor todavía.

La respuesta correcta es:

git bisect skip
Bisecting: 6 revisions left to test after this (roughly 3 steps)
[5b8e2d4a9c7f1e3b6d0a8c2f4e7b9d1a3c5f8e2b] Corregir la llave sobrante en app.js

skip le dice a Git: "este commit no me da información". Git elige otro candidato cercano y sigue. Si acabas saltándote demasiados y no puede aislar uno solo, te lo dirá:

There are only 'skip'ped commits left to test.
The first bad commit could be any of:
 3e7a1c5 ...
 8d4f2a7 ...
 b2c9e4f ...
We cannot bisect more!

En ese caso te da un conjunto pequeño de sospechosos, que ya es infinitamente mejor que 214.

También se pueden saltar rangos enteros, si sabes que toda una zona no compila:

git bisect skip a1b2c3d..e4f5a6b

Motivos habituales para skip: el commit no compila, faltan dependencias que aún no estaban en el package.json, o el commit es de un cambio de formato masivo que hace imposible ejecutar nada. skip es tu amigo; mentir a bisect no.

  1. git bisect log y replay: no perder el trabajo

A mitad de una bisección larga puede pasar de todo: te equivocas al marcar, se cierra el terminal, o tienes que atender otra cosa. git bisect log guarda la sesión:

git bisect log
git bisect start
# status: waiting for both good and bad commits
# bad: [c8f2a1e...] Añadir el contador de tareas pendientes
git bisect bad c8f2a1e...
# status: waiting for good commit(s), bad commit known
# good: [1a5c9f3...] Version 1.0.0
git bisect good 1a5c9f3...
# good: [9f3c7a1...] Extraer la validación del formulario a su función
git bisect good 9f3c7a1...
# bad: [4e8b2c9...] Añadir el filtro por estado de la tarea
git bisect bad 4e8b2c9...

Guardarlo y recuperarlo después:

git bisect log > /tmp/sesion-borrado.txt
git bisect reset                       # dejarlo por hoy

# Mañana, o en otra máquina:
git bisect replay /tmp/sesion-borrado.txt

replay reconstruye la sesión hasta donde llegaste y te deja en el siguiente commit por probar.

Y aquí está su otro uso, el más valioso: corregir un error de marcado. Si te das cuenta de que dijiste good donde debías decir bad, no hay que empezar de cero:

git bisect log > /tmp/sesion.txt
# Editar /tmp/sesion.txt: quitar la línea equivocada y las posteriores
git bisect reset
git bisect replay /tmp/sesion.txt

  1. Automatizar con git bisect run

La sesión manual funciona, pero ocho vueltas de "abrir el navegador, crear una tarea, pulsar, marcar" son ocho oportunidades de equivocarse y veinte minutos de la vida de Carla.

Si la comprobación se puede escribir como un comando que devuelve 0 cuando está bien y algo distinto de 0 cuando está mal, git bisect run lo hace todo solo:

git bisect start main v1.0.0
git bisect run <comando>

Git prueba, ejecuta el comando, interpreta el código de salida, marca, avanza y repite hasta encontrar el culpable. Sin intervención humana.

Con una batería de pruebas, es literalmente esto:

git bisect start main v1.0.0
git bisect run npm test

Y para un solo caso, puede bastar un comando de una línea:

# ¿Existe todavía la función borrarTarea en app.js?
git bisect run grep -q "function borrarTarea" app.js

grep -q devuelve 0 si encuentra, 1 si no. Es exactamente el contrato que bisect run espera.

  1. El script de prueba para gestor-tareas

Como el fallo de Carla es de comportamiento en el navegador, hace falta un script algo más elaborado. El equipo tiene una prueba automatizada con Node:

#!/usr/bin/env bash
#
# herramientas/probar-borrado.sh
# Devuelve 0 si el borrado de tareas funciona, 1 si no.
# Pensado para 'git bisect run'.

set -uo pipefail        # OJO: sin -e, queremos controlar los códigos a mano

# 1. ¿Están los ficheros que necesitamos? Si no, este commit no es evaluable.
if [ ! -f app.js ] || [ ! -f index.html ]; then
  echo "→ Faltan ficheros del proyecto: commit no evaluable"
  exit 125
fi

# 2. Dependencias: se instalan sin ruido; si falla, no es culpa del código
if [ -f package.json ]; then
  npm ci --silent >/dev/null 2>&1 || {
    echo "→ npm ci ha fallado: commit no evaluable"
    exit 125
  }
fi

# 3. ¿El JavaScript es sintácticamente válido? Si no, tampoco es evaluable
if ! node --check app.js >/dev/null 2>&1; then
  echo "→ app.js no es sintácticamente válido: commit no evaluable"
  exit 125
fi

# 4. La prueba de verdad
if node herramientas/prueba-borrado.mjs >/dev/null 2>&1; then
  echo "→ El borrado FUNCIONA"
  exit 0
else
  echo "→ El borrado NO funciona"
  exit 1
fi

Y la prueba en sí, que simula el DOM mínimo necesario:

// herramientas/prueba-borrado.mjs
// Prueba mínima: crear una tarea, borrarla y comprobar que desaparece.
import { JSDOM } from 'jsdom';
import { readFileSync } from 'node:fs';

const html = readFileSync('index.html', 'utf8');
const dom = new JSDOM(html, { runScripts: 'outside-only' });
const { document } = dom.window;

// Exponer el entorno del navegador que espera app.js
globalThis.window = dom.window;
globalThis.document = document;
globalThis.localStorage = dom.window.localStorage;

// Cargar la aplicación
dom.window.eval(readFileSync('app.js', 'utf8'));

// 1. Crear una tarea
const campo = document.querySelector('#nueva-tarea');
const formulario = document.querySelector('#formulario-tarea');
campo.value = 'Tarea de prueba del bisect';
formulario.dispatchEvent(new dom.window.Event('submit'));

if (document.querySelectorAll('.tarea').length !== 1) {
  console.error('La tarea no se ha creado: la prueba no es concluyente');
  process.exit(125);           // no evaluable, no es el fallo que buscamos
}

// 2. Pulsar el icono de la papelera (el <svg> dentro del botón)
const icono = document.querySelector('.tarea .borrar svg')
           ?? document.querySelector('.tarea .borrar');
icono.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));

// 3. ¿Ha desaparecido?
const quedan = document.querySelectorAll('.tarea').length;
process.exit(quedan === 0 ? 0 : 1);

Los tres puntos que hacen que este script funcione bien con bisect:

  1. Prueba una sola cosa. Solo el borrado. Si la prueba fallara también por otros motivos, bisect encontraría el primer commit que rompe cualquier cosa, que no es lo que buscamos.
  2. Distingue "no funciona" de "no se puede evaluar". Ese es el papel del 125, y es lo que veremos ahora.
  3. Es silencioso y determinista. Sin salida interactiva, sin depender de la red, sin fallos intermitentes. Una prueba que a veces falla envenena toda la bisección.

Lanzándolo:

git bisect start main v1.0.0
git bisect run ./herramientas/probar-borrado.sh
running './herramientas/probar-borrado.sh'
→ El borrado FUNCIONA
Bisecting: 53 revisions left to test after this (roughly 6 steps)
[4e8b2c9] Añadir el filtro por estado de la tarea
running './herramientas/probar-borrado.sh'
→ El borrado NO funciona
Bisecting: 26 revisions left to test after this (roughly 5 steps)
...
8d4f2a7c1e6b9d3f5a8c0e2b4d7f9a1c3e5b8d6f is the first bad commit
commit 8d4f2a7c1e6b9d3f5a8c0e2b4d7f9a1c3e5b8d6f
Author: Bruno Salas <[email protected]>
Date:   Mon Jun 22 11:14:38 2026 +0200

    Delegar los eventos del listado en el contenedor

 app.js | 22 ++++++++++------------
bisect run success

Cuarenta segundos, sin tocar el teclado. Y una advertencia sobre el #!: el script debe ser ejecutable (chmod +x) y estar fuera del árbol de trabajo o presente en todos los commits. Si vive dentro del repositorio y en los commits antiguos no existía, bisect no lo encontrará al hacer checkout de esos commits. La solución habitual es copiarlo a /tmp y ejecutarlo desde allí:

cp herramientas/probar-borrado.sh /tmp/probar.sh
git bisect run /tmp/probar.sh

  1. Los códigos de salida, con detalle

Este es el contrato completo de git bisect run, y conocerlo es la diferencia entre una automatización que funciona y una que da resultados falsos:

Código de salida Significado para bisect Cuándo usarlo
0 El commit es bueno La comprobación pasa
1–124 El commit es malo La comprobación falla
125 Saltar este commit (skip) No se puede evaluar: no compila, faltan dependencias
126 Malo (el comando no es ejecutable) Error de permisos: revísalo
127 Malo (comando no encontrado) Error de ruta: revísalo
128–255 Aborta la bisección Error grave; Git se detiene y avisa

Tres consecuencias prácticas:

  • 125 es la pieza clave. Es el equivalente automático de git bisect skip. Sin él, un commit que no compila se marcaría como "malo" y bisect te señalaría un culpable equivocado.
  • 126 y 127 son trampas mortales. Si el script no es ejecutable o la ruta está mal, bisect marcará todos los commits como malos y "encontrará" como culpable el primero del rango. Si el resultado te parece absurdo, comprueba primero que el script se ejecuta a mano: ./mi-script.sh; echo $?.
  • Evita exit 128 y superiores. Un kill -9 de un proceso da 137, por ejemplo. Si tu script puede terminar así, envuélvelo para normalizar el código de salida.

Comprobación previa obligatoria, siempre, antes de lanzar un bisect run:

# En un commit que sabes BUENO
git switch --detach v1.0.0
./herramientas/probar-borrado.sh; echo "código: $?"     # debe ser 0

# En un commit que sabes MALO
git switch --detach main
./herramientas/probar-borrado.sh; echo "código: $?"     # debe ser 1

Treinta segundos que evitan biseccionar durante diez minutos hacia una respuesta inventada.

  1. --first-parent: saltarse el interior de las fusiones

El historial de gestor-tareas tiene fusiones (lección 03-03), y bisect por defecto explora todos los commits alcanzables, incluidos los internos de cada rama fusionada. Eso tiene dos inconvenientes:

  • Los commits intermedios de una rama en desarrollo suelen estar a medias: puede que ni compilen, y acabas haciendo muchos skip.
  • A efectos de main, lo que importa es en qué fusión entró el problema, no cuál de los seis commits de la rama lo introdujo.
git bisect start --first-parent main v1.0.0

Con --first-parent, Git solo considera la línea principal: los commits directos de main y los commits de fusión, sin entrar dentro de las ramas. Es exactamente el mismo concepto de --first-parent que veremos en git log en la lección 06-04, y el mismo "primer padre" que elegía -m 1 al revertir una fusión (lección 05-06).

gitGraph
   commit id: "v1.0.0"
   commit id: "a1"
   branch funcionalidad/eventos
   commit id: "f1"
   commit id: "f2"
   commit id: "f3"
   checkout main
   commit id: "a2"
   merge funcionalidad/eventos id: "M1"
   commit id: "a3"

Sin --first-parent, los candidatos son a1, f1, f2, f3, a2, M1, a3. Con --first-parent, solo a1, a2, M1, a3.

Cuándo usar cada uno:

Situación Modo
El equipo fusiona ramas y main siempre está sano --first-parent: más rápido, menos skip
Quieres el commit exacto dentro de la rama culpable Sin --first-parent
El historial es lineal (rebase antes de integrar) Da igual: no hay fusiones
Muchos commits intermedios rotos --first-parent, casi obligado

La táctica de dos fases es la mejor de las dos: primero --first-parent para identificar la fusión, después una segunda bisección dentro de esa rama:

git bisect start --first-parent main v1.0.0
git bisect run /tmp/probar.sh
# → resulta ser la fusión M1

git bisect reset
git bisect start M1^2 M1^1        # dentro de la rama: malo=punta, bueno=base
git bisect run /tmp/probar.sh

M1^2 es el segundo padre (la punta de la rama fusionada) y M1^1 el primero (la base en main), con la notación de la lección 02-06.

  1. git bisect terms: cuando no es "bueno/malo"

bisect no es solo para buscar regresiones. Sirve para localizar cualquier cambio de estado monótono en el historial. Los ejemplos típicos:

  • ¿En qué commit se arregló un fallo? (aquí "bueno" y "malo" están invertidos)
  • ¿En qué commit el arranque pasó de tardar 200 ms a 900 ms?
  • ¿En qué commit el paquete pasó de 400 KB a 1,2 MB?

En esos casos, la terminología por defecto confunde. git bisect terms permite renombrarla:

git bisect start --term-old=roto --term-new=arreglado
git bisect roto v1.0.0
git bisect arreglado main

A partir de ahí, en cada paso respondes git bisect roto o git bisect arreglado, y Git busca el primer commit arreglado.

Alias Equivale a Significado
--term-old / --term-good good El estado antiguo, el de antes del cambio
--term-new / --term-bad bad El estado nuevo, el que buscamos

Lo que hay que entender es que a Git le dan igual las palabras: siempre busca el primer commit con el estado "nuevo", sea "roto", "arreglado", "lento" o "gordo". Consultar los términos activos en cualquier momento:

git bisect terms
Your current terms are roto for the old state
and arreglado for the new state.

Un ejemplo de rendimiento, midiendo con el propio script:

#!/usr/bin/env bash
# /tmp/probar-tamano.sh — 0 si el bundle pesa menos de 500 KB
npm ci --silent >/dev/null 2>&1 || exit 125
npm run build --silent >/dev/null 2>&1 || exit 125
bytes=$(wc -c < dist/app.min.js)
echo "→ ${bytes} bytes"
[ "$bytes" -lt 512000 ]
git bisect start --term-old=ligero --term-new=pesado
git bisect ligero v1.0.0
git bisect pesado main
git bisect run /tmp/probar-tamano.sh

La última línea del script es la que decide: [ "$bytes" -lt 512000 ] devuelve 0 si se cumple y 1 si no, que es justo el contrato de bisect run.

  1. Qué hace que un historial sea bisecable

bisect es una herramienta que te devuelve lo que le diste. Su eficacia depende directamente de la calidad del historial, y ahí es donde el módulo 5 y este se dan la mano:

Propiedad del historial Efecto sobre bisect
Cada commit compila y arranca La bisección es limpia, sin skip
Commits pequeños y atómicos El culpable es un diff de 10 líneas, no de 800
Un cambio lógico por commit El diff señala la causa directamente
Mensajes descriptivos Entiendes el porqué del cambio culpable al instante
Commits gigantes "trabajo del viernes" Encuentras el commit... y sigues sin saber qué línea
Commits que no compilan skip continuo, resultado difuso o inconcluyente
Ramas fusionadas con historia sucia Ruido: se resuelve con --first-parent

Dicho de otro modo: el rebase interactivo de la lección 05-02 no es cosmética. Cuando consolidas los fixup!, divides un commit gigante y te aseguras de que cada uno deja el proyecto en un estado funcional, lo que estás construyendo es un historial bisecable. La factura de no hacerlo se paga el día en que hay que encontrar una regresión de hace dos meses.

Y con --exec (lección 05-02) puedes comprobar por adelantado que toda una rama es bisecable antes de integrarla:

git rebase -i --exec "node --check app.js" origin/main

Git aplica cada commit y ejecuta la comprobación en cada uno. Si alguno falla, te detiene ahí.

Todo esto —commits atómicos, historial que compila, qué consolidar antes de integrar— es el tema de la lección 08-02: Manteniendo un Historial Limpio. bisect es la mejor justificación práctica que existe para esas prácticas: convierte una virtud abstracta en minutos de trabajo ahorrados.

Una última nota de alcance: bisect responde a "¿qué commit cambió este comportamiento?". No responde a "¿por qué mi repositorio está en un estado extraño?" ni "¿qué está haciendo Git por debajo?". Ese diagnóstico —reflog, fsck, GIT_TRACE y compañía— es el terreno de las lecciones del módulo 9, y en particular de la 09-06: Técnicas Avanzadas de Depuración.

Errores Comunes y Consejos

Error 1: empezar con el árbol de trabajo sucio. bisect hace checkout en cada paso y fallará o arrastrará tus cambios entre commits. Confirma o haz git stash antes.

Error 2: olvidar git bisect reset. Te quedas en detached HEAD sobre un commit de junio. Todo commit que hagas ahí quedará huérfano.

Error 3: invertir good y bad al empezar. El orden del atajo es git bisect start <MALO> <BUENO>. Si lo inviertes, Git buscará en el sentido contrario y no encontrará nada coherente.

Error 4: marcar bad un commit que está roto por otro motivo. Usa skip. Mentirle a bisect produce un culpable equivocado con toda la apariencia de ser correcto.

Error 5: olvidar el exit 125 en el script. Sin él, los commits que no compilan cuentan como malos y el resultado es basura.

Error 6: un script no ejecutable o con la ruta mal. Códigos 126/127: todo sale "malo" y el culpable es el primer commit del rango. Prueba el script a mano antes.

Error 7: dejar el script dentro del repositorio. En los commits antiguos puede no existir. Cópialo a /tmp y ejecútalo desde fuera.

Error 8: una prueba no determinista. Si falla una de cada cinco veces, bisect te dará una respuesta distinta cada vez. Asegura el determinismo antes de automatizar.

Consejo 1: elige el "bueno" más reciente que puedas. Empezar en v1.0.0 en lugar de en el primer commit del proyecto ahorra pasos. Las etiquetas (lección 05-05) son perfectas para esto.

Consejo 2: guarda siempre git bisect log. Una sesión larga es trabajo valioso. Un fichero de texto te permite retomarla o corregir un marcado erróneo.

Consejo 3: primero --first-parent, después dentro de la rama. Rápido para localizar la fusión, preciso para localizar el commit.

Consejo 4: bisect run con npm test es a menudo suficiente. Si ya tienes pruebas, no escribas nada nuevo.

Consejo 5: cuando encuentres el culpable, no lo revientes sin más. Míralo, entiéndelo y decide: git revert si está publicado (lección 05-06), o una corrección hacia delante si el cambio en sí era bueno y solo faltaba un detalle —como en el caso de Bruno, donde la delegación de eventos era correcta y solo faltaba closest().

Ejercicios

Ejercicio 1: bisección manual

Crea un repositorio con 15 commits donde el fichero valor.txt contiene 10, y a partir del commit número 9 pasa a contener 99 (el "fallo"). Después:

  1. Inicia una bisección con main como malo y el primer commit como bueno.
  2. En cada paso, comprueba cat valor.txt y marca good o bad.
  3. Comprueba que Git identifica exactamente el commit 9.
  4. ¿Cuántos pasos has necesitado? ¿Coincide con log₂(14)?

Ejercicio 2: bisección automática con run

Con el mismo repositorio del ejercicio 1:

  1. Escribe un script en /tmp que devuelva 0 si valor.txt contiene 10 y 1 si no.
  2. Prueba el script a mano en un commit bueno y en uno malo antes de usarlo.
  3. Lanza git bisect run y comprueba que encuentra el mismo commit.
  4. Añade al repositorio un commit intermedio en el que valor.txt no exista, y haz que el script devuelva 125 en ese caso. Comprueba que la bisección sigue funcionando.

Ejercicio 3: términos personalizados

Crea un repositorio de 10 commits donde estado.txt contiene roto y a partir del commit 7 pasa a contener arreglado. Usando --term-old y --term-new, encuentra el primer commit arreglado. Escribe también el bisect run correspondiente.

Soluciones

Solución 1:

mkdir /tmp/practica-bisect && cd /tmp/practica-bisect
git init -b main

for i in $(seq 1 15); do
  if [ "$i" -lt 9 ]; then echo "10" > valor.txt; else echo "99" > valor.txt; fi
  echo "linea $i" >> registro.txt
  git add .
  git commit -q -m "Commit numero $i"
done

git log --oneline | tail -1        # el primer commit (bueno)
2a7c9e1 Commit numero 1
git bisect start main 2a7c9e1
Bisecting: 6 revisions left to test after this (roughly 3 steps)
[8f3d2c7] Commit numero 8
cat valor.txt          # 10  → bueno
git bisect good
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[5c9a4e2] Commit numero 12
cat valor.txt          # 99  → malo
git bisect bad
Bisecting: 1 revision left to test after this (roughly 1 step)
[1e6b8d3] Commit numero 10
cat valor.txt          # 99  → malo
git bisect bad
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[7b2f5a9] Commit numero 9
cat valor.txt          # 99  → malo
git bisect bad
7b2f5a9... is the first bad commit
commit 7b2f5a9...
    Commit numero 9
 valor.txt | 2 +-
git bisect reset

Cuatro pruebas para 14 candidatos. log₂(14) ≈ 3,8, que redondeado hacia arriba son 4. Exacto.

Solución 2:

cat > /tmp/probar-valor.sh <<'FIN'
#!/usr/bin/env bash
# 0 = bueno, 1 = malo, 125 = no evaluable
[ -f valor.txt ] || { echo "→ sin valor.txt: no evaluable"; exit 125; }
valor=$(cat valor.txt)
echo "→ valor = $valor"
[ "$valor" = "10" ]
FIN
chmod +x /tmp/probar-valor.sh

Comprobación previa, que nunca hay que saltarse:

cd /tmp/practica-bisect
git switch --detach $(git log --oneline | tail -1 | cut -d' ' -f1)
/tmp/probar-valor.sh; echo "código: $?"
→ valor = 10
código: 0
git switch main
/tmp/probar-valor.sh; echo "código: $?"
→ valor = 99
código: 1
git bisect start main $(git log --oneline | tail -1 | cut -d' ' -f1)
git bisect run /tmp/probar-valor.sh
running '/tmp/probar-valor.sh'
→ valor = 10
Bisecting: 3 revisions left to test after this (roughly 2 steps)
running '/tmp/probar-valor.sh'
→ valor = 99
...
7b2f5a9... is the first bad commit
    Commit numero 9
bisect run success
git bisect reset

El commit no evaluable:

git switch -c con-hueco main
git rm -q valor.txt && git commit -q -m "Quitar temporalmente valor.txt"
echo "99" > valor.txt && git add . && git commit -q -m "Restaurar valor.txt"

git bisect start con-hueco $(git log --oneline main | tail -1 | cut -d' ' -f1)
git bisect run /tmp/probar-valor.sh

Cuando la bisección llega al commit sin valor.txt, el script devuelve 125 y Git lo salta:

running '/tmp/probar-valor.sh'
→ sin valor.txt: no evaluable
Bisecting: 1 revision left to test after this (roughly 1 step)

El culpable sigue siendo el commit 9: el 125 ha evitado que un commit inevaluable falseara el resultado.

Solución 3:

mkdir /tmp/practica-bisect-terms && cd /tmp/practica-bisect-terms
git init -b main

for i in $(seq 1 10); do
  if [ "$i" -lt 7 ]; then echo "roto" > estado.txt; else echo "arreglado" > estado.txt; fi
  echo "linea $i" >> registro.txt
  git add . && git commit -q -m "Commit numero $i"
done

primero=$(git log --oneline | tail -1 | cut -d' ' -f1)
git bisect start --term-old=roto --term-new=arreglado
git bisect roto "$primero"
git bisect arreglado main
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[9c4e7b2] Commit numero 5
cat estado.txt         # roto
git bisect roto
cat estado.txt         # arreglado
git bisect arreglado
cat estado.txt         # arreglado
git bisect arreglado
4f8a2e6... is the first arreglado commit
commit 4f8a2e6...
    Commit numero 7

Con automatización:

git bisect reset

cat > /tmp/probar-estado.sh <<'FIN'
#!/usr/bin/env bash
[ -f estado.txt ] || exit 125
grep -q '^roto$' estado.txt     # 0 = estado antiguo (roto), 1 = nuevo
FIN
chmod +x /tmp/probar-estado.sh

git bisect start --term-old=roto --term-new=arreglado
git bisect roto "$primero"
git bisect arreglado main
git bisect run /tmp/probar-estado.sh
git bisect reset

Lo importante: aunque los términos se llamen roto y arreglado, el script sigue devolviendo 0 para el estado antiguo y 1 para el nuevo. bisect no interpreta las palabras; solo busca la frontera.

Conclusión

git bisect convierte una búsqueda desesperada en un procedimiento mecánico y acotado. Lo esencial:

  • Es una búsqueda binaria sobre el historial: cada prueba descarta la mitad de los candidatos, así que hacen falta log₂(n) pruebas. 214 commits son 8 pruebas; un millón, veinte.
  • La sesión manual es startbadgood → bucle de pruebas → reset. El reset final no es opcional: sin él te quedas en detached HEAD.
  • git bisect skip es la respuesta honesta a un commit que no se puede evaluar. Mentir marcando good o bad produce un culpable falso.
  • git bisect log y replay salvan sesiones largas y permiten corregir un marcado erróneo sin empezar de cero.
  • git bisect run <comando> automatiza todo el proceso. El contrato son los códigos de salida: 0 bueno, 1–124 malo, 125 saltar, 128+ abortar. El 125 es lo que separa una automatización fiable de una que inventa respuestas.
  • Prueba siempre el script a mano en un commit bueno y en uno malo antes de lanzar bisect run, y sácalo del árbol de trabajo (/tmp) para que exista en todos los commits.
  • --first-parent limita la búsqueda a la línea principal: menos ruido y menos skip. La táctica de dos fases —primero la fusión, después dentro de la rama— combina velocidad y precisión.
  • git bisect terms generaliza la herramienta a cualquier cambio de estado monótono: cuándo se arregló algo, cuándo empezó a ir lento, cuándo engordó el paquete.
  • Y la conclusión de fondo: un historial de commits pequeños que compilan es un historial bisecable. La lección 08-02 lo desarrolla; bisect es la razón por la que importa.

Carla ya tiene el commit culpable y el diff exacto. Pero al mirar app.js para arreglarlo se encuentra otra cosa: veinte líneas más abajo hay una función de normalización de texto que nadie sabe para qué está, con un replace de caracteres raros que parece arbitrario. Nadie del equipo recuerda haberla escrito. Borrarla parece tentador... y probablemente rompería algo.

Antes de tocar código que no entiendes, hay que saber quién escribió cada línea, cuándo y en qué commit, para poder leer el mensaje que explica el porqué. Esa es la herramienta de la lección 06-03: Git Blame.

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