De tekst die ik zocht toen ik startte met git
De tekst die ik zocht toen ik startte met git
Toen de dieren nog spraken, heb ik aan projecten gewerkt die CVS gebruikten als versiebeheersysteem. Daarna werkte ik verschillende jaren met subversion. Maar sinds enkele maanden heb ik ook subversion gedumpt, en gebruik ik alleen nog git. Als ik kan kiezen tenminste. Dit zijn de redenen:
Je kunt committen als je internetconnectie het laat afweten.
Je kunt vrij gemakkelijk configureren dat niet iedereen schrijfrechten heeft op de master- en de releasebranches.
Je kunt al eens lokaal iets uitproberen en wijzigingen committen, zonder dat je daarvoor op de centrale repository een branch moet maken.
Feature branches maken en switchen tussen branches gaat veel makkelijker, en ook zonder internetconnectie.
Git heeft veel meer features dan subversion.
Linus zegt: 'If you are using subversion, you are stupid and ugly.' ;-)
Er zijn ook wel wat problemen met een migratie naar git:
Git werkt op een heel andere manier dan subversion, en het vraagt tijd om goed te begrijpen wat het doet
Git werkt het gemakkelijkst via de command line, en dat is voor sommige users een hoge drempel.
Ik mocht een bestaande server gebruiken met git en gitolite. En ook de conversie van subversion naar git ging vrij gemakkelijk. We konden git gebruiken, het werkte, al wist ik niet helemaal hoe dat kwam. Dat lijkt overigens een meer gehoord probleem te zijn. Vandaar dat ik nu de tekst schrijf die ik graag had gelezen toen ik met git begon. Want hier en daar zitten er wel wat rariteiten in onze repo, die waarschijnlijk vermeden hadden kunnen worden, als ik beter wist waar ik mee bezig was.
Twee disclaimers:
Ik bespreek hier enkel de concepten, je vindt hier geen concrete git-commando's. Dat is bewust :) Mogelijk schrijf ik ooit nog een praktisch vervolg op deze tekst.
Van sommige technische zaken maak ik abstractie, om de lengte van de tekst te kunnen beperken.
Git is gedistribueerd
Git is gedistribueerd. Er is in principe geen centrale repository, en iedere developer heeft een kopie van de volledige repository staan. Wil je een nieuwe kopie, dan kloon je gewoon een bestaande.
In je eigen repository kun je referenties bewaren naar andere remote reposity's. Als je een repository kloont, dan wordt sowieso een referentie gelegd naar het orginineel, die dan de naam 'origin' krijgt.
Commits
Een git repository is in grote lijnen niets meer dan een gerichte acyclische graph met commits, waar een commit een specifieke revisie van je source code is. Iedere commit wordt bepaald door een SHA-1-hash, niets meer dan een (in praktijk) unieke checksum. Vaak spreekt men kortweg over een 'SHA'.
Een SHA is 40 karakters lang, dat is erg veel om te typen. Als er geen dubbelzinnigheid is, kun je naar een commit verwijzen met de eerste karakters van de SHA. Meestal kom je met 5 à 6 karakters toe.
Iedere commit (behalve de initiële) heeft een verwijzing naar één of twee ouders. Die verwijzing gebeurt via de SHA. Door van een willekeurige commit die verwijzigingen te blijven volgen tot de initiële commit, kun je heel de geschiedenis van die revisie van de source code terugvinden.
In het meest eenvoudige geval, is een git repository gewoon een opeenvolging van commits, waarbij iedere commit hoogstens één ouder en hoogstens één kind heeft. De geschiedenis is dan bij wijze van spreke een rechte lijn:
C1 <- C2 <- C3 <- C4
``C1`` is de initiële commit. Om de schema's niet te overladen, ga ik verderop in de tekst de 'pijltjes' voor de ouderrelatie (``<-``) niet meer als dusdanig afbeelden. De conventie is dat de parent links staat, en de children rechts.
In de meeste gevallen is het echter niet zo dat alle revisies in een git-repository elkaar mooi opvolgen. In geval van branchen heeft een commit typisch meerdere kinderen. Bij een merge-operatie heeft een commit precies twee ouders. Maar daarover later meer.
Voorbeeld van een ingewikkelder repository:
C1 -- C2 -- C3 -- C4 -- C5 -- D6 -- D7 | | +--- D3 -- D4 -- D5 ----+ | | +--- F5 -- F6 | | +--- E3 -- E4--- E5
Bij het programmeren is er altijd één commit uitgecheckt. Deze commit is
de HEAD
van je repository. De huidige source code (working copy)
komt overeen de code van HEAD
, met daarop een een aantal wijzigingen
die je maakte. Er kunnen bestanden toegevoegd, verwijderd, of aangepast
zijn.
Als je nu een nieuwe revisie van je code wilt committen, moet je aan git laten weten welke van de wijzigingen in de working copy meegenomen moeten worden. Als je wilt dat een wijziging mee gecommit wordt, dan moet je die toevoegen aan de 'index': de wijziging 'stagen'. Een nieuwe commit bevat alle wijzigingen van de index. Zaken die je aan de source veranderde, maar niet stagede, blijven weliswaar gewijzigd in jouw versie van de source code, maar worden niet mee opgenomen in de commit.
HEAD ↓ C1 -- C2 -- C3 -- C4 -- staged changes -- working copy
... wordt na committen:
HEAD ↓ C1 -- C2 -- C3 -- C4 -- C5 -- working copy
Na een commit schuift HEAD
een plaatsje op, zodat die opnieuw wijst
naar de commit waarop je working copy gebaseerd is.
Als git-repository's met elkaar praten, dan wisselen ze typisch commits uit. Op de duur krijg je er zo heel wat bij elkaar, en de vraag is dan hoe je daar je weg nog in vindt. Hier is een oplossing voor: branches.
Branches
Branches zijn in se niets meer dan pointers naar een bepaalde commit in
je repository. Net zoals HEAD
er eentje is, eigenlijk. Een branch
uitchecken, komt neer op het uitchecken van de commit waar de branch
naar wijst. Git weet in welke branch je aan het werken bent, en als je
een nieuwe revisie commit, dan schuift niet alleen HEAD
op, maar ook
de pointer van de huidige branch.
Branches kun je namen geven zoals je wilt, maar typisch is er één die
master
heet. De master
-branch is de 'mainline', die bevat de
meest up-to-date developmentversie.
Voorbeeld:
master ↓ HEAD C1 -- C2 -- C3 -- C4 branch2 | ↓ +--- D3 -- D4 -- D5 | | +--- F5 -- F6 | | ↑ +--- E3 -- E4 branch4 ↑ branch3
Branches in je eigen kopie van de repository, zijn lokale branches. De branches in een remote repository kun je ook zien, dat zijn dan remote branches. Het is mogelijk om remote branches te 'fetchen'; dan worden alle relevante commits overgehaald naar je eigen repository, alsook de pointer van de remote branch.
(origin) master ↓ C1 -- C2 -- C3 -- C4 branch2 | ↓ +--- D3 -- D4 -- D5 -- D6 | | +--- F5 -- F6 | | ↑ +--- E3 -- E4 branch4 ↑ branch3 (lokaal) HEAD master ↓ C1 -- C2 -- C3 -- C4 -- C5
Fetchen van ``origin/branch4`` geeft in dit voorbeeld
(lokaal) HEAD master ↓ C1 -- C2 -- C3 -- C4 -- C5 | +--- E3 -- F4 -- F5 -- F6 ↑ origin/branch4
Je kunt niet rechtstreeks committen een branch in een remote repository. De geijkte manier van werken is dat je eerst de remote branch fetcht, dat je die koppelt aan een lokale branch, en dat je dan lokaal je nieuwe commits maakt. Een dergelijke lokale branch die gekoppeld is aan een remote branch, heet een '(remote) tracking branch'.
Van een tracking branch weet git waar het origineel zit, zodat je de recentste wijzigingen in de remote branch makkelijk kunt downloaden. Git zal je ook informeren over de verschillen tussen je de remote branch en je gekoppelde lokale tracking branch.
Terug naar het voorbeeld van daarnet. Als je een lokale branch ``branch4`` maakt, als remote tracking branch voor ``origin/branch4``, en je checkt die uit, dan is de situatie als volgt:
master ↓ C1 -- C2 -- C3 -- C4 -- C5 | +--- E3 -- F4 -- F5 -- F6 ↑ origin/branch4 branch4 HEAD
Maar voor de rest gedraagt een tracking branch zich net hetzelfde als
een gewone lokale branch. Als hij uitgechekt is, en je commit, dan
schuift de pointer van je tracking branch gewoon mee op met HEAD
.
master ↓ HEAD C1 -- C2 -- C3 -- C4 -- C5 branch4 | ↓ +--- E3 -- F4 -- F5 -- F6 -- F7 -- F8 ↑ origin/branch4
Mergen
Als een commit 2 parents heeft, dan spreken we over een 'merge-operatie'. Het idee achter mergen is dat je de wijzigingen van een andere branch toepast op je uitgecheckte branch. Die wijzigingen worden bepaald op basis van de recentste gemeenschappelijke voorouder van de uitgecheckte branch en de te mergen branch.
In het eenvoudigste geval, is je uitgecheckte branch zelf een voorouder
van de branch die je wilt mergen. (Dit komt vaker voor dan je initieel
zou denken). Git zal dan gewoon de pointer van je uitgecheckte branch
verleggen naar de commit waar de te mergen branch naar wijst. De
verlegde branch wordt dan opnieuw uitgecheckt, zodat HEAD
ook
opschuift. We spreken van een 'fast forward merge'. Goed onthouden, want
dat is een belangrijk concept: een fast forward merge is een merge die
enkel neerkomt op het verleggen van de pointer van een branch.
HEAD branch1 ↓ C1 -- C2 -- C3 -- C4 | +--- D5 -- D6 ↑ branch2
Na merge van branch2 op branch1:
C1 -- C2 -- C3 -- C4 | +--- D5 -- D6 ↓ branch1 branch2 HEAD
(Ik heb de knik in de tekening laten zitten, maar uiteraard is die niet van belang)
Een fast forward merge is niet altijd mogelijk. Als je uitgecheckte branch geen voorouder is van de te mergen branch, dan volstaat het verleggen van een pointer niet.
HEAD branch1 ↓ C1 -- C2 -- C3 -- C4 -- C5 | +--- D5 -- D6 ↑ branch2
In dit geval gaat git op zoek naar de recentste gemeenschappelijke voorouder. Op die voorouder worden dan de wijzigingen van daar tot de uitgecheckte branch toegepast, en de wijzigingen van daar tot de te mergen branch.
Onderstaand voorbeeld laat zien hoe ``branch2`` gemerged wordt in ``branch1``.
HEAD branch1 ↓ C1 -- C2 -- C3 -- C4 -- C5------- C6 | | +--- D5 -- D6 --+ ↑ branch2
In het beste geval gaat dat probleemloos. Git maakt dan een nieuwe commit in de uitgecheckte branch.
Als er conflicten zijn tussen de 2 sets wijzigingen, dan zal git je files wel aanpassen, maar zal het resultaat nog niet gecommit zijn. Je zult dan manueel de conflicten moeten oplossen (git zal je vertellen over welke files het gaat), alvorens het resultaat te committen.
Pull en push
Stel dat je in een remote tracking branch aan het werken bent, en dat je de laatste commits van die remote branch wilt toepassen op jouw branch. Dan kun je een 'pull'-operatie uitvoeren. Git zal dan de remote branch opnieuw fetchen, en die mergen in jouw tracking branch.
Bijvoorbeeld: Toen in onderstaand voorbeeld ``remote/branch1`` naar ``C3`` wees, maakte je een remote tracking branch. Sindsdien werd remote een commit ``C4`` bijgemaakt, terwijl jij lokaal ``C4'``, ``C5'`` en ``C6'`` committe.
(origin) branch1 ↓ C1 -- C2 -- C3 -- C4 (lokaal) HEAD branch1 (trackt remote/branch1) ↓ C1 -- C2 -- C3 -- C4' -- C5'-- C6' ↑ remote/branch1
Na een fetch-operatie van ``remote/branch1``, ziet de lokale repo er als volgt uit:
HEAD branch1 ↓ C1 -- C2 -- C3 -- C4' -- C5'-- C6' | +--- C4 ↑ remote/branch1
Tenslotte wordt gemerged
HEAD branch1 ↓ C1 -- C2 -- C3 -- C4' -- C5'-- C6' -- C7' | | +--- C4 ------------------+ ↑ remote/branch1
Net zoals bij iedere andere merge, zou het kunnen dat dit conflicten veroorzaakt, die je dan zult moeten oplossen.
Omgekeerd kun je de commits in een uitgecheckte branch 'pushen' naar een
branch in een remote repository. Dat kan zowel naar een nieuwe als naar
een bestaande remote branch zijn. Git uploadt dan de commit waar
HEAD
naar wijst, samen met de nodige voorouders om jouw HEAD
te
koppelen aan de remote commits.
Als de remote branch al bestond, worden jouw lokale commits gemerged in de remote branch. Maar in de meeste configuraties gebeurt dat enkel als die merge-operatie een fast forward merge is. In de andere gevallen krijg je een foutmelding.
In zo'n geval pull je eerst de remote branch, zodat de merge-operatie in jouw lokale repository afgehandeld kan worden. Die merge-operatie resulteert dan lokaal in een nieuwe commit, met als ouders jouw recentste commit en de recentste commit uit de remote repository. Als je dan opnieuw pusht, zal aan de remote kant wel fast forward gemerged kunnen worden.
Rebasen
'Rebasen' komt min of meer neer op het 'verleggen' van een branch. Stel
dat je een branch hebt gemaakt, op basis van master
. Intussen zijn
er aan die branch nieuwe commits toegevoegd, en ook master
heeft
nieuwe commits.
Je kunt nu je branch rebasen op master
. Git gaat op zoek naar de
gemeenschappelijke voorouder van master
en jouw branch. Vertrekkende
van dat punt, zal git alle commits in jouw branch aflopen, en bekijken
wat er precies gewijzigd is bij deze commits. Dan maakt git een nieuwe
branch vertrekkende van de huidige master
, en worden in die nieuwe
branch nieuwe commits gemaakt, door dezelfde wijzigingen door te voeren
in de nieuwe branch. Mogelijk treden er onderweg conflicten op, omdat
wijzigingen in master
de wijzigingen uit de branch in de weg staan.
Die moet je dan onderweg oplossen. Als alle commits zijn overgedaan,
wordt de pointer van je branch verlegd naar de nieuw aangemaakte branch,
en wordt die uitgecheckt.
Bijvoorbeeld: na een commit ``C4`` in de ``master`` branch, maakte je een nieuwe branch. Daarin committe je de wijzigingen ``D5`` en ``D6``.
master ↓ C1 -- C2 -- C3 -- C4 -- C5 -- C6 -- C7 | +--- D5 -- D6 ↑ branch2 HEAD
Intussen zijn er ook nieuwe commits in de master branch. Nu wil je graag dat ``branch2`` verlegd wordt naar de huidige toestand van de master branch (``C7``). In dat geval spreekt men van een 'rebase' van ``branch2`` op ``master``.
Git gaat op zoek naar de recentste gemeenschappelijke 'voorouder' van ``branch2`` en ``master``. In dit voorbeeld is dat ``C4``. Nu worden de wijzigingen die nodig waren om van ``C4`` naar ``D5`` te gaan toegepast op ``C7``. Dit wordt gecommit, en op die nieuwe commit worden dan de wijzigingen voor de overgang van ``D5`` naar ``D6`` toegepast. Met het volgende resultaat:
master ↓ C1 -- C2 -- C3 -- C4 -- C5 -- C6 -- C7 | +--- D5' -- D6' ↑ branch2 HEAD
Let op! Rebasen doe je enkel met branches die niemand anders wordt geacht te tracken. Bij het rebasen verander je namelijk de geschiedenis van een branch. Als een collega commits toevoegt aan een branch die jij hebt verlegd, en hij probeert te pushen of te pullen, dan eindigt dat ongetwijfeld met miserie.
Workflow
Je kunt met git veel kanten uit. Op dit moment is mijn manier van werken als volgt:
Master-branch
De master
-branch bevat de recentste werkbare code. Dat mag met
experimentele features zijn, maar de bedoeling is wel dat de code
compileert, en geacht wordt goed te werken.
Feature branches
Iedere keer als je aan een nieuwe feature begint, maak je een feature-branch.
Bijvoorbeeld:
master ↓ C1 -- C2 -- C3 -- C4 | +--- D5 -- D6 -- D7 ↑ feature1 HEAD
Als je in een feature branch code commit die niet helemaal werkt, of
half broken is, is dat geen probleem. Enkel master
wordt geacht in
orde te zijn.
Stel dat er nu plots een bug wordt gevonden, die dringend gefixt moet
worden. In dat geval kun je makkelijk master
opnieuwe uitchecken, en
een nieuwe branch maken voor je bugfix. Je zult dan geen last hebben van
mogelijke problemen of onvolkomenheden van je half-afgewerkte feature.
master ↓ C1 -- C2 -- C3 -- C4 | +--- D5 -- D6 -- D7 | ↑ +--- E5 feature1 ↑ bugfix HEAD
Is je bugfix afgewerkt, en er is ondertussen niets aan master
veranderd, dan kun je die bugfix makkelijk fast forward mergen.
HEAD bugfix master ↓ C1 -- C2 -- C3 -- C4 -- E5 | +--- D5 -- D6 -- D7 ↑ feature1
De bugfixbranch is na de merge van geen belang meer, en kan verwijderd worden. Je checkt je feature branch opnieuw uit, en kunt dan zonder problemen verder werken aan waar je mee bezig was.
Na een tijdje is die feature af, en wil je ook mergen. Dat kan niet meer
via een fast forward merge, want ondertussen is de master branch
opgeschoven. (Omwille van de bugfix van daarnet.) Om de geschiedenis van
je code dan overzichtelijk te houden, is het dan interessanter om de
feature-branch te rebasen alvorens hem te mergen naar master
.
Rebase dus eerst ``feature1`` op ``master``:
master ↓ C1 -- C2 -- C3 -- C4 -- E5 | +--- D5' -- D6' -- D7' ↑ feature1 HEAD
Hierna kun je fast forward mergen:
HEAD feature1 master ↓ C1 -- C2 -- C3 -- C4 -- E5 -- D5' -- D6' -- D7'
Een feature-branch is typisch een branch waar jij alleen aan werkt; er is niemand anders die die trackt. Rebasen is dus geen probleem, en op die manier heeft elke commit slechts 1 parent, wat overzichtelijker is als je de geschiedenis van je project wilt bekijken. Bij een gewone merge die niet fast-forward is, heb je een commit met 2 parents, en dat maakt het ingewikkelder. Als je dat kunt vermijden, moet je dat doen.
Release-branches
Stel dat er een release dichtbij komt. Dan splits je een release-branch
af van master
. In het begin bevat die uiteraard nog niets speciaals.
release-1 master ↓ C1 -- C2 -- C3 -- C4
Typisch zijn er een aantal bugs die nog gefixt moeten worden voor de release. Maar de gewone ontwikkeling gaat verder in master.
release-1 master ↓ ↓ C1 -- C2 -- C3 -- C4 -- C5 -- C6
Stel nu dat je een release-critical bug wilt fixen. Dan doe je die fix in de release branch.
master ↓ C1 -- C2 -- C3 -- C4 -- C5 -- C6 | +--- D5 ↑ release-1
Maar je wilt deze fix natuurlijk ook toepassen op de master branch. Hier
is het uiteraard geen optie om eerst de release-branch te rebasen op
master
, want dan zouden de nieuwe features die intussen naar master
gecommit zijn, ook in de releasebranch zitten. En dat is uiteraard niet
de bedoeling. In dit geval merge je de release-branch gewoon in
master
.
master ↓ C1 -- C2 -- C3 -- C4 -- C5 -- C6 -- C7 | | +--- D5 ---------+ ↑ release-1
Na zo'n merge mag de release-branch uiteraard ook niet verwijderd worden, want die heb je achteraf nog nodig om verdere release-critical-bugs te committen.
Ingrijpende refactoring
Een laatste use case die ik wil bespreken, is een ingrijpende refactoring. Hiervoor maak je ook een branch.
Omdat zo'n refactoring wel wat tijd in beslag zal nemen, en omdat je tijdens het refactoren graag feedback hebt, wil je je refactoring branch ook publiek beschikbaar maken.
Publieke branches rebasen is meestal niet zo'n goed idee. Want zoals
gezegd geeft dat problemen als iemand anders jouw branch trackt.
Vermijden dus. Als je de laatste zaken uit master
ook in je
refactoring branch wilt trekken, dan kun je ook beter direct mergen.
That's all
Voilà. Een bescheiden introductie tot git. Hier en daar heb ik dingen weggeabstraheerd, om de tekst niet te lang te maken, en natuurlijk ook omdat ik zelf nog niet alles beheers :-)
De manier van werken die ik beschrijf, werkt voor mij. Ik ben niet zeker of het echt volgens de best practices is. Als je feedback hebt, dan ben ik daar zeker in geïnteresseerd.
Deze tekst is ook beschikbaar op github. Daar kun je commentaar geven (post gerust een issue), of zelfs pull requests sturen, als je hem wilt verbeteren :)
Commentaar
Comments powered by Disqus