Een branching model om bij te leren
Er zijn verschillende modellen om aan branching te doen met Git. Een heel klassieke is de main, develop, featureA, featureB, waarbij develop
regelmatig terug in main
wordt gevoegd. Die is geschikt voor het ontwikkelen van software, maar versiebeheer kan ook heel zinvol zijn voor andere taken. Bijvoorbeeld als studietool. In deze post doe ik een voorstel voor een single-developer
branching model dat er helemaal anders uitziet en dat bedoeld is om leesbare en studeerbare voorstellingen van nuttige kennis op te leveren.
Het idee achter dit model is dat we één repository opbouwen voor alles wat we leren en dat een branch een gekristalliseerde voorstelling van een taak is. Met andere woorden: exact wat je nodig hebt om te begrijpen hoe de taak wordt opgelost, zonder extra ballast. Als een taak voortbouwt op een andere taak die op zichzelf zinvol is, wordt dat een nieuwe branch. Als een taak een combinatie van deeltaken vereist, wordt dat ook een nieuwe branch, maar omvat die een merge van de eerdere taken. Het resultaat is een "dependency graph" voor kennis.
Voorbeeld
Eerst een voorbeeld, daarna geef ik tips over hoe je dit kan implementeren. Stel dat we via onze repository willen bijleren over de basis van webontwikkeling. De taak die we voor ogen hebben is een webpagina waarop we de output van een stukje JavaScript willen tonen. Dan zouden onze branches er als volgt kunnen uitzien:
Je merkt dat elke branch, met uitzondering van main, eindigt op "... voltooid". Er kunnen dan nog afsplitsingen zijn waarin we verder gaan, maar de branch zelf zou niet meer mogen groeien. Het uiteinde drukt uit hoe we de taak vervullen die in de naam omschreven is.
Er is geen "ultieme" voorstelling. Het kan zijn dat je JavaScript voor het eerst leert via <script>-tags of via Node.js. Dus de commit graph kan er wat anders uitzien naargelang hoe jij
dit zou benaderen. Maar de essentie blijft dat je kijkt naar kleine
deeltaken, dat deze een eigen branch krijgen en dat een merge aangeeft dat je deeltaken combineert. Deeltaken kunnen dan nog verder opgedeeld worden in tussenstappen. Die verwezenlijken op zichzelf misschien niet veel, maar maken duidelijker van waar het resultaat komt. Dan kan de implementatie van een volledige deeltaak achteraf makkelijk herbekeken worden via git diff hash-van-het-eindresultaat hash-van-het-begin. En het kritieke pad "from scratch" kan je achterhalen via git log --graph hash-of-branchnaam-van-het-eindresultaat
Implementatie
Het gebeurt zelden dat je bij de eerste poging een taak perfect oplost, met de ideale opdeling in tussenstappen. Dit model brengt daarom veel branch rewriting met zich mee. Daar wordt soms vies naar gekeken, maar hier is het geen probleem. Het model is bedoeld voor volledig persoonlijke repositories. Je gaat dus niet plots merken dat de geschiedenis van je project onverwacht herschreven is.
Snapshot als startpunt
De bedoeling van dit model is dat je je eerdere werk kan reproduceren. Dan is het belangrijk de invloed van externe factoren te minimaliseren. Je wil bijvoorbeeld niet dat een demonstratie plots niet meer werkt omdat je andere packages op je systeem hebt geïnstalleerd, andere services op de achtergrond aan het uitvoeren bent, enzovoort.
Daarom werkt dit model best met een heel stabiele basis, bijvoorbeeld een snapshot van een VM of misschien een Docker container op basis van een heel specifieke tag. Soms moet je wel zaken installeren of settings aanpassen om een demonstratie te kunnen doen. Dan zet je bijvoorbeeld een prepare_branch.sh
script op de branch die de nodige packages installeert enzovoort. Je commit alle branches op een remote repository. Als je van branch wil wisselen, ga je terug naar je vaste snapshot, pull je alles en run je terug het geschikte prepare_branch.sh
script.
Voordeel NixOS VM
Ik sprak van een "heel stabiele" basis, maar een snapshot van een VM en scripts zijn niet helemaal reproduceerbaar. Dependencies kunnen updates krijgen, packages in de repository van ons OS kunnen updaten,... Het model lijkt me sowieso de moeite, maar om nog meer
garanties te krijgen, pas ik de aanpak toe in een NixOS VM. Mijn repository bevat een flake.nix
met daarin een volledige specificatie van mijn systeem. Dat betekent dat mijn builds deterministisch
reproduceerbaar zijn tot op het niveau van de gecompileerde binaire bestanden. Het betekent ook minder werk bij het wisselen van branches, want het is niet nodig de VM snapshot te ontdubbelen en terug in de gewenste toestand te brengen. Een nixos rebuild switch
bereikt hier hetzelfde, maar normaal sneller.
Niet noodzakelijk en ik zou er niet meteen voor overstappen naar Nix, maar als je al ervaring hebt met Nix of erg Nix-curious bent, raad ik het wel aan.
De main branch
Op mijn main branch staat een absolute basisconfiguratie met tools die ik vrijwel altijd nodig heb. In mijn geval zijn dat bijvoorbeeld Git zelf, Neovim (zonder uitgebreide configuratie) en Zellij (een terminal multiplexer). Ik hou mijn basisomgeving graag beperkt, zodat de basisomgeving op desnoods op zich kan worden uitgelegd. Heel af en toe update ik mijn basisomgeving. Dan kan ik andere deelbomen op de nieuwste commit voor de basisomgeving rebasen (al hoeft dat niet altijd meteen). Meer daarover beneden.
.gitattributes
Een voordeel van deze aanpak is dat het makkelijk wordt een stappenplan op te stellen via een sequentie van git diff
operaties, van de huidige tak tot main. Dit toont soms ook wijzigingen die je niet noodzakelijk wil zien om het geheel te begrijpen, zoals de wijzigingen aan een package-lock.json
file. Dit kan met .gitattributes.
Interactieve rebases
Elke commit stelt een duidelijke tussenstap voor. In de praktijk maken we wel eens fouten. We schrijven code met bugs, met redundante stappen,... Hier komt git rebase -i
van pas. We kunnen commits bundelen, commits splitsen, commit messages herschrijven,... Probeer het model in het begin redelijk
nauwgezet toe te passen, maar wees niet te perfectionistisch. Herbekijk af en toe je repository en trim, verduidelijk,... achteraf. Je hoeft hier niet noodzakelijk een vast tijdstip voor in te boeken. Je zal het ook wel merken als je een taak weken of maanden niet aanraakt en dan terug nodig hebt om iets complexer te realiseren.
Met deze aanpak is het ook logisch, als we een branch transplanteren, de daarop voortbouwende branches mee te transplanteren. Dit is niet wat een normale rebase doet. Die zal maakt eigenlijk een nieuwe tak met de naam van de getransplanteerde tak, maar de oorspronkelijke commit blijft bestaan en alles wat daar op voortbouwde, blijft voortbouwen op die commit. Je kan dit oplossen met --update-refs.
Als je main
updatet, hoef je ook niet meteen alle bestaande takken meteen te transplanteren. Ik zou het wel doen vooraleer je nieuw
werk op basis van een bestaande tak doet, maar er is niet echt een reden om meteen alles volledig te transplanteren. Integendeel, na het transplanteren van een tak test je die best, dus if it ain't broken, don't fix it.
Aanpak merges
In dit model betekent een merge dat twee of meer taken bouwstenen zijn voor een complexere taak. Aangezien een merge normaal één van de twee takken verlengt, maak ik voor het mergen steeds een nieuwe tak aan op één van de twee uiteindes, doe ik daarvan een checkout en voer ik dan pas de merge uit. Zo blijven de oudertakken staan zoals ze stonden. Ik commit eerst het resultaat van de letterlijke merge, zelfs als er wat herschikt moet worden om de twee bouwstenen samen te voegen. Het punt is hier niet om code samen te voegen, maar om ideeën samen te voegen. De Git graph moet dit weerspiegelen.
Naamgeving branches
Met deze aanpak produceer je typisch heel veel branches met vrij weinig code per branch. Het wordt snel moeilijk demonstraties terug te vinden op basis van alleen de namen van branches. Een conventie voor naamgeving kan helpen. Je kan bijvoorbeeld werken met een namespace per interessegebied of overkoepelende technologie. Dit kan je gewoonweg doen door deze te verwerken in de namen van je branches, bijvoorbeeld nix__, python__,... aan het begin van elke branchnaam. Als je dan een specifieke branch zoekt, kan je pattern matchen op de start van de naam.
Als je je echt aan het verdiepen bent, moet je misschien namespaces nesten, bijvoorbeeld python__flask__. Ik zou beginnen met alleen "top-level" prefixen. Als je merkt dat je naar eigen aanvoelen te veel branches hebt met hetzelfde prefix, kan je een extra niveau van nesting introduceren en bestaande branches hernoemen.
Overzicht bewaren
Ik raad aan dat je iets voorziet om automatisch op te volgen dat conventies worden nageleefd. Dat kan via een CI/CD systeem, via een shell hook,...
Het kan ook zijn dat je per branch wat meer detail wil bijhouden rond wat de bedoeling is, waarom je het zo aanpakt,... Als je merkt dat dit zo is, raad ik aan een BRANCH-README
toe te voegen op de eerste commit van elke branch die het doel duidelijk omschrijft. Je kan dit eventueel ook gewoon via de eerste commit message op een branch doen, of via git notes.
Er is ook de eerdere afspraak rond hoe merge commits moeten verlopen. Ik gebruik voor al deze checks client-side Git hooks. Die zijn standaard niet version controlled, maar ik heb ook een shell hook ingesteld in mijn NixOS configuratie die deze hooks meteen installeert. Ze zijn niet
voorzien op alle mogelijke truukjes die je met Git kan uitvoeren (en je kan natuurlijk altijd --no-verify
gebruiken) maar ik denk dat dat vrijwel onmogelijk wordt met een collectie hooks. Een soort globale controle die periodiek wordt uitgevoerd is waarschijnlijk beter en mogelijk voorzie ik die nog als de hooks zelf niet voldoende fouten voorkomen.
Coda
Beperkingen
Afhankelijk van de exacte manier waarop je dit toepast, maak je bepaalde zaken lastiger om mee te experimenteren. Bijvoorbeeld Git zelf. Als je repository alles bevat wat je geleerd hebt, moet je bijna repositories gaan nesten om experimenten met Git zelf bij te houden. Dat kan, maar wel op een nogal indirecte manier. Een workaround kan bijvoorbeeld zijn om een script te maken dat naar een andere directory navigeert en daar de gewenste stappen uitvoert. Je plaatst dan dat script onder versiebeheer.
Ook het gebruik van andere VM's moet op een indirecte manier verlopen. Geneste virtualisatie moet dan gebruikt worden en er moet opnieuw met scripts gewerkt worden. De VM's zelf in Git bijhouden is niet echt een realistische optie, maar een cloud-init file bijhouden wel.
Hulpmiddelen
Het schrijven van deze post heeft me een aantal nieuwe ideeën opgeleverd. Ik plan in de toekomst aan te vullen met een tutorial rond hoe je zelf een gelijkaardige NixOS-in-VM aanpak kan opzetten, inclusief hooks voor automatische kwaliteitscontrole. Ik zal dan deze post updaten met een link.