Veel repositories hebben niet één hoofdlijn. Een library houdt een 2.x- en een 3.x-branch in leven omdat nog niet alle klanten over zijn. Een product wordt geleverd vanaf release/1.4 terwijl release/1.5 nog getest wordt. Een plugin bestaat één keer per versie van het platform waarin hij draait. Zit er een bug in code die al die lijnen delen, dan is de fix één beslissing en meerdere stukken werk.
De meeste AI-codingtools, de onze tot voor kort ook, gaan ervan uit dat een taak één wijziging op de standaardbranch betekent. Alles daarna, de backports en de cherry-picks die niet schoon toepassen, bleef liggen voor een mens. Dit stuk gaat over wat we bouwden zodat een taak kan zeggen op welke branches hij van toepassing is, en over wat we bewust niet bouwden.
Een taak noemt zijn branches
Een taak kan een lijst branch targets hebben. Elk target is één repository op één branch, en elk krijgt een eigen checkout en een eigen pull request naar die branch. Eén taak, één goedgekeurde specificatie, één run, en zoveel pull requests als er branches zijn die een wijziging nodig hadden.
Je hoeft de branches niet elke keer te kiezen. Een repository kan zijn maintained branches vastleggen, de branches die je naast elkaar onderhoudt, en aangeven of nieuwe taken op al die branches van toepassing zijn of alleen op de standaardbranch. Die standaard wordt ingevuld zodra de taak wordt aangemaakt, of dat nu via het formulier, de nieuwe-taak-chat of de API gebeurt. Je ziet hem dus op de taak en kunt hem daar inperken voordat er iets draait. "Alle versies" in de chat betekent precies die lijst.
Daaronder liggen twee kleine regels. Dezelfde branch van dezelfde repository mag maar één keer op een taak staan, want twee pull requests vanuit één taak naar één branch zitten elkaar alleen in de weg. En branchnamen worden vóór het opslaan getoetst aan een behoudende deelverzameling van de regels van git, omdat ze in git-commando's en API-aanroepen naar je git-host terechtkomen.
Eén checkout per branch, naast elkaar
Bij de start van de run wordt elk target gekloond in een eigen map in dezelfde werkruimte, genoemd naar de repository en de branch. Elk krijgt ook een eigen werkbranch, met de basisbranch als achtervoegsel. Dat laatste detail voorkomt een voorspelbare bug: met één gedeelde naam voor meerdere targets van dezelfde repository zou elke push de vorige overschrijven.
De agent ziet ze daarna allemaal tegelijk. Zijn instructies zeggen dat dit dezelfde repository op verschillende momenten in zijn geschiedenis kan zijn, dat afhankelijkheden, frameworkversies, API's en de bestandsindeling kunnen verschillen, en dat hij de wijziging in elke genoemde map moet doorvoeren, aangepast aan wat er op die branch staat in plaats van gekopieerd. Ook staat er dat hij de map van de ene branch niet mag aanpassen om een andere te repareren.
Dat is de belangrijkste reden dat we kozen voor één run over alle branches in plaats van één run per branch. Eén agentsessie die de 2.x- en de 3.x-code naast elkaar kan lezen, ziet waarom de fix er op elke branch anders uitziet. Losse runs zouden het probleem elk opnieuw moeten ontdekken.
Daarna doorloopt elk target dezelfde verificatie, self-review en securitycontrole als elke andere run. Een target waar niets veranderde, wordt overgeslagen en krijgt geen pull request. Dat is de eerlijke uitkomst als een bug op een oudere lijn simpelweg niet bestaat.
Een nieuwe versielijn starten
Het andere verzoek dat we steeds hoorden, was het omgekeerde: niet "repareer dit overal" maar "begin aan de volgende versie". Een target kan daarom aangeven dat zijn branch nog niet bestaat en moet worden aangemaakt vanaf een andere.
Dat doen we in twee stappen. Eerst wordt de nieuwe branch gepusht als ongewijzigde kopie van de bronbranch. Daarna doet de run het upgradewerk daarbovenop, en dat werk komt binnen als pull request naar de nieuwe branch. Het alternatief, een nieuwe branch pushen waarvan de eerste commit de upgrade van de AI al bevat, zou van de belangrijkste wijziging van de hele lijn de enige maken die niemand reviewde.
De push wordt nooit geforceerd. Is de branch intussen op de remote verschenen, dan faalt hij in plaats van te overschrijven; heeft een eerdere poging van dezelfde taak hem al aangemaakt, dan bouwt de run daarop voort. Bestaat de branch waarvan gekopieerd moet worden niet, dan stopt de run en zegt hij welke het is.
Wat "upgraden naar de volgende versie" betekent, leggen we niet vast. Er zit geen ecosysteemspecifieke logica in deze functie. De agent krijgt de opdracht te bekijken hoe de eerdere lijnen in de repository van elkaar verschillen en daaruit af te leiden wat een upgrade hier inhoudt: versiemetadata, afhankelijkheden, migratiestappen, wat de eigen geschiedenis van het project ook laat zien. Staat de upgrade-pull request open, dan bieden de repository-instellingen de nieuwe branch aan als maintained branch. Of het een lijn wordt die je onderhoudt, is jouw beslissing, dus we voegen hem niet zelf toe.
Wat we fout deden
De chat start een nieuwe lijn vanaf de nieuwste maintained branch, en de branchkiezer toont branches op volgorde. Allebei sorteerden ze eerst als gewone tekst, en dan komt 10.0 vóór 9.0. Voor elk product voorbij versie 9 was "de nieuwste lijn" dus de verkeerde. Branchnamen sorteren nu natuurlijk: 9.0 vóór 10.0 en release/2 vóór release/10. Een kleine bug, maar hij had een nieuwe versie opgeleverd die van een oude was gekopieerd.
Wat het niet doet
- Het doet niet aan forward-porten of cherry-picken. Elke branch krijgt een eigen implementatie, en dat is precies de bedoeling, maar het betekent ook dat elke pull request een eigen review nodig heeft.
- Het controleert bij het opslaan van de taak niet of een branch bestaat. Daarvoor is de git-host nodig, dus een ontbrekende branch wordt bij de start van de run bij naam gemeld.
- Meer branches betekent een grotere run. De agent leest en wijzigt elke genoemde checkout in één sessie, dus een taak met veel branches is een grotere run om te betalen en meer pull requests om te reviewen.
- Op self-hosted runners hebben runs met meerdere targets een runner nodig die recent genoeg is. Oudere runners slaan zulke runs over, en een run starten zonder geschikte runner wordt geweigerd met een reden in plaats van dat hij in de wachtrij blijft hangen.
Waar je begint
Onderhoud je meer dan één lijn, leg ze dan eerst vast op de repository en laat nieuwe taken op de standaardbranch staan. Kies daarna één echte bug die op twee van die lijnen voorkomt, zet de targets op die taak en vergelijk de twee pull requests. De verschillen laten snel zien of de agent je branches begreep of alleen een diff kopieerde. De specificatie waarop beide pull requests gebouwd zijn, beschrijven we bij spec-driven development.