Soms werk ik aan een project waarbij een functie plots kapot blijkt te zijn, terwijl niemand weet wanneer het precies is misgegaan. De branch heeft inmiddels honderden commits, de laatste release was weken geleden, en het handmatig doorlopen van de geschiedenis is geen optie. Precies in dit soort situaties pak ik git bisect erbij. Het is een commando dat de meeste developers wel kennen maar zelden echt toepassen, en dat is jammer, want het scheelt enorm veel tijd.
Hoe git bisect werkt
Het principe achter git bisect is binair zoeken door de commitgeschiedenis. Je vertelt Git welke commit zeker goed was en welke commit zeker slecht is, en Git checkt dan automatisch de middelste commit uit. Jij test, vertelt Git of die commit goed of slecht is, en dat herhaalt zich totdat Git de exacte commit aanwijst waar het fout ging. Bij honderd commits zijn dat maximaal zeven stappen.
git bisect start
git bisect bad # huidige commit is kapot
git bisect good v1.4.0 # deze tag werkte nog correct
Na het invoeren van deze drie regels checkt Git een commit halverwege de geschiedenis uit. Je test dan je applicatie of draait een specifieke test, en geeft Git daarna feedback met git bisect good of git bisect bad. Git verkleint telkens het zoekbereik totdat het de schuldige commit vindt. Wanneer de sessie klaar is, sluit je hem af met git bisect reset, zodat je weer terug bent op je originele branch.
Wat ik persoonlijk verrassend vind, is dat developers bij bugs vaak meteen in de code duiken zonder eerst te bepalen wanneer het probleem is ontstaan. Met git bisect draai je dat om: je weet eerst precies welke commit de schuld is, en dan pas kijk je naar de code. Dat is een stuk effectiever.
Automatisch bisecten met een testscript
Het handmatig testen van elke stap werkt prima, maar zodra je een geautomatiseerde test hebt die het probleem reproduceert, kun je het hele proces overlaten aan Git. Je koppelt dan een script aan de sessie met git bisect run. Git voert dat script uit na elke stap en interpreteert de exit code: nul betekent goed, alles boven nul betekent slecht.
git bisect start
git bisect bad HEAD
git bisect good origin/main~50
git bisect run php artisan test --filter=CartTotalTest
In dit geval laat ik een specifieke PHPUnit-test draaien voor elke commit die Git uitcheckt. Zodra de test slaagt geeft het script exit code nul terug, zodra hij faalt krijgt Git een foutcode en weet hij dat die commit slecht is. Zonder verdere tussenkomst van mijn kant doorloopt Git zo de hele geschiedenis en rapporteert aan het einde de exacte commit.
# Uitvoer na het automatisch bisecten
a3f92e1 is the first bad commit
commit a3f92e1c...
Author: Pim <[email protected]>
Date: Thu Mar 14 10:22:04 2024 +0100
Refactor cart calculation to apply discounts before tax
Nu weet ik meteen dat het probleem in die refactor zit. Een blik op git show a3f92e1 vertelt me vervolgens exact welke regels zijn gewijzigd. Het voordeel van de geautomatiseerde variant is dat ik intussen gewoon andere dingen doe terwijl Git zelf aan het werk is.
Omgaan met commits die je niet kunt testen
Niet elke commit is testbaar. Soms bevat een tussenliggende commit een broken build, ontbrekende migraties, of tijdelijk weggegooide code die de applicatie crasht voordat je ook maar iets kunt testen. Git bisect heeft daar een commando voor: git bisect skip. Daarmee vertel je Git dat deze commit niet bruikbaar is als testpunt, en zoekt Git een andere commit in de buurt.
# Commit is niet testbaar, sla hem over
git bisect skip
Het risico is dat als de foute commit zelf tussen de overgeslagen commits zit, Git dat niet met zekerheid kan vaststellen. In dat geval geeft Git een eerlijk antwoord terug: het kan niet precies één commit aanwijzen, maar wel een klein bereik waarbinnen het probleem zit. Dat is nog altijd veel bruikbaarder dan blind zoeken.
Wat ik ook weleens doe, is een aparte testomgeving opzetten met een bisect run-script dat eerst de migraties draait, dan de tests uitvoert, en bij een mislukte migration stap de sessie skipt in plaats van stopt. Dat vergt wat extra werk in het script, maar maakt het bisecten betrouwbaarder in projecten met een actieve migratiegeschiedenis.
#!/bin/bash
# bisect_test.sh
# Draai migraties, stop als ze falen zonder het als bad te markeren
php artisan migrate 2>/dev/null || exit 125 # 125 = skip
# Draai de specifieke test
php artisan test --filter=CartTotalTest
exit $?
Exit code 125 is de speciale waarde die Git herkent als skip-signaal wanneer je bisect run gebruikt. Dit patroon pas ik toe als een project migraties heeft die afhankelijk zijn van code die misschien nog niet bestond in een oudere commit.
Logs en referenties bewaren na een bisect-sessie
Na afloop van een succesvolle bisect-sessie wil je de bevindingen niet kwijtraken. git bisect log schrijft de volledige sessie weg naar een tekstbestand, inclusief alle stappen, de commits die als goed of slecht zijn gemarkeerd, en de eindconclusie. Dat bestand kun je opslaan of meesturen in een bug report.
git bisect log > bisect-sessie-2024-03-14.txt
Andersom kun je ook een opgeslagen log opnieuw afspelen met git bisect replay. Als een collega een bisect-sessie heeft uitgevoerd en je wil zien hoe hij tot zijn conclusie is gekomen, of als je een sessie wil hervatten die je halverwege hebt afgebroken, laad je het logbestand opnieuw in.
git bisect replay bisect-sessie-2024-03-14.txt
Wat ik verder handig vind, is dat je binnen een bisect-sessie gewoon git log HEAD kunt uitvoeren om te zien waar je je bevindt in de geschiedenis. Git geeft ook tussendoor aan hoeveel stappen er nog ruwweg te gaan zijn, wat helpt om in te schatten hoe lang de sessie nog duurt. Die transparantie maakt het gereedschap minder magisch en makkelijker te vertrouwen.
Teams die regelmatig regressies tegenkomen, maar git bisect niet structureel inzetten, besteden onnodig tijd aan het handmatig doorzoeken van code. Het kost misschien tien minuten om een goede testcase te schrijven die het probleem reproduceert, maar die tien minuten betalen zich meteen terug zodra Git binnen een minuut of twee de exacte commit aanwijst. En die testcase blijft daarna gewoon in de testsuite zitten als regressietest.
Zelf merk ik dat ik sindsdien anders omga met het melden van bugs intern. Als iemand een regressie rapporteert, is de eerste vraag niet meer "wat is er kapot" maar "wanneer werkte het voor het laatst". Zodra je dat weet, heb je alle informatie die je nodig hebt om een bisect-sessie te starten en binnen enkele minuten het antwoord te hebben. Dat verandert de manier waarop je naar de commitgeschiedenis kijkt: niet als archief, maar als actief diagnostisch hulpmiddel.