Git, branches en Pull Requests — hoe teams werken
Van git init tot push en pull, .venv en .env buiten de repository houden met .gitignore, kleine feature-branches, nette commitberichten en Pull Requests — plus een Python-tool die git log leest om commits te controleren.
Hoe wil je leren?
Het probleem dat we oplossen
Een AI-engineer heeft een week lang gewerkt aan een project voor ticket-triage: de prompt aangepast, de lijst met zoekwoorden uitgebreid, één functie herschreven. Op vrijdag zijn de resultaten slechter dan op maandag. Welke wijziging is de boosdoener? De map bevat triage.py, triage_v2.py, triage_final.py en triage_final_REAL.py — en niemand weet wat er in welke versie is veranderd.
Ondertussen heeft een collega die hetzelfde bestand in een gedeelde map bewerkte, zijn werk overschreven. En een maand geleden heeft iemand voor een snelle test een API-sleutel rechtstreeks in de code geplakt, en via die map is hij bij tien anderen terechtgekomen.
Alle drie de problemen hebben één antwoord, en bijna elk softwareteam ter wereld gebruikt het: bewaar de geschiedenis met Git, werk aan elk onderdeel op een eigen branch, en laat iemand anders het werk reviewen in een Pull Request voordat het bij de hoofdcode komt. Dit hoofdstuk leert die hele cyclus — en eindigt met een Python-tool die een van de taken van de reviewer automatiseert.
Aan het einde van dit hoofdstuk kun je
- De geschiedenis van een project bijhouden en delen met
git init,add,commit,pushenpull .venven.envbuiten de repository houden met.gitignore, en weten wat je moet doen als er per ongeluk één is gecommit- Werken op kleine, kortlevende feature-branches en een nette Pull Request openen
- Commitberichten schrijven zoals reviewers ze lezen
- Een Python-tool schrijven die
git logviasubprocessleest en commitberichten controleert
Wat je eerst nodig hebt: De AI-engineer track, en ontwikkelomgevingen met uv — vooral uv init en uv run.
Eerste keer instellen
Git houdt bij wie elke commit heeft gemaakt. Dus de eerste keer stel je jezelf één keer voor. Het is ook de moeite waard om Git te vertellen dat nieuwe repositories de hoofdbranch main moeten noemen, omdat de meeste teams dat nu gebruiken:
git config --global user.name "Nadia Karim"
git config --global user.email "[email protected]"
git config --global init.defaultBranch mainGebruik je eigen naam en het e-mailadres waarmee je account op de code-hostingdienst is geregistreerd. Zonder de laatste regel noemen oudere Git-versies de hoofdbranch master — dat werkt, maar het past niet bij de rest van het team.
Drie plekken: de map, de staging area en de geschiedenis
In het vorige hoofdstuk heeft uv init zelf een Git-repository aangemaakt. Het voorbeeldproject in dit hoofdstuk begint op dezelfde manier:
uv init ticket-triage --python 3.12
cd ticket-triageGit denkt in drie plekken, en elk commando verplaatst iets van de ene naar de andere:
- De werkmap — je map, waar je de bestanden bewerkt
- De staging area — de lijst met wat in de volgende commit komt;
git addzet dingen hierin - De geschiedenis — de keten van commits;
git commitmaakt van de staging area een blijvende commit
Waarom een aparte stap voor staging? Omdat je midden in een taak misschien vijf bestanden hebt gewijzigd, waarvan er twee een bug oplossen en drie een feature toevoegen. Met staging kun je ze splitsen in twee aparte, betekenisvolle commits — dat is voor een reviewer heel veel waard.
Het commando om te zien waar je staat is git status, of in de korte vorm git status --short.
.gitignore: wat nooit in de repository terechtkomt
AI-projecten hebben bijna altijd een .env-bestand met API-sleutels. Stel dat het project zo'n bestand krijgt (sk-... hier is alleen een placeholder, geen echte sleutel):
LLM_API_KEY=sk-...De .gitignore die uv init aanmaakt, bevat .venv maar niet .env. Dus de eerste taak is om .env onderaan toe te voegen:
# Secrets
.envDraai nu één keer uv run main.py (zodat .venv en uv.lock worden aangemaakt) en controleer de status:
git status --short
?? .gitignore
?? .python-version
?? README.md
?? main.py
?? pyproject.toml
?? uv.lock?? betekent dat Git het bestand ziet, maar het nog niet bijhoudt. Het echte nieuws is wat niet in de lijst staat: geen .venv, geen .env. Beide staan in de map, maar Git doet alsof het ze niet ziet. (.venv heeft eigenlijk twee lagen bescherming: wanneer uv de omgeving aanmaakt, zet het er ook een klein .gitignore-bestand in dat alleen * bevat.) En uv.lock staat er wel — die moet worden gecommit, want daarmee krijgen collega's precies dezelfde omgeving.
Hoe weten collega's dan welke sleutels ze nodig hebben? Gebruikelijk is om een .env.example-bestand te committen met de namen, maar zonder de waarden. Een nieuweling kopieert het naar .env en vult zijn eigen sleutels in.
Een geheim dat eenmaal is gecommit, verdwijnt niet door het in de volgende commit te verwijderen. Git is er juist voor om elke eerdere toestand te onthouden. De sleutel blijft dus voor altijd in de oudere commit staan — iedereen kan hem metgit showeruit halen. Zodra een sleutel naar een remote is gepusht, is de enige veilige stap naar de dienst gaan waar de sleutel bij hoort, hem intrekken en een nieuwe aanmaken. Daarom moet.envin.gitignorestaan voordat het bestand ooit wordt aangemaakt.
De eerste commit, en goede commitberichten
git add .
git commit -m "chore: scaffold project with uv"
[main (root-commit) 0f78e59] chore: scaffold project with uv
6 files changed, 35 insertions(+)
create mode 100644 .gitignore
create mode 100644 .python-version
create mode 100644 README.md
create mode 100644 main.py
create mode 100644 pyproject.toml
create mode 100644 uv.lock0f78e59 is de identiteit van de commit (het begin van de hash) — op jouw machine is die anders, omdat de tijd en de auteur erin verwerkt zijn.
Een commitbericht is geschreven voor een toekomstige lezer — jezelf over zes maanden, en de reviewer van vandaag. Veel teams volgen een gangbare stijl die Conventional Commits heet:
type(scope): short summary in the imperative- type zegt wat voor soort wijziging het is:
feat(een nieuwe mogelijkheid),fix(een bugfix),docs,refactor,test,chore(onderhoud) - scope is optioneel, tussen haakjes, en noemt welk deel van het project het betreft:
feat(triage): ... - summary staat in de gebiedende wijs (“add”, “fix”, “remove” — niet “added”), binnen 72 tekens, en zonder punt aan het eind
Naast elkaar is het verschil duidelijk:
wip
fixed stuff
Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
fix(triage): lowercase ticket text before keyword matchingDe eerste drie geven geen foutmelding — Git accepteert elk bericht. Maar in git log zeggen de eerste twee niets, en de derde is zo lang dat hij wordt afgekapt in een overzicht met één regel per commit. Bij het laatste bericht weet een reviewer meteen wat er is veranderd en waar. Als het “waarom” meer ruimte nodig heeft, gaat het in de body van het bericht, na een lege regel.
Branches: elk stuk werk in zijn eigen kamer
De main-branch is het waardevolste van het team: wat daar staat werkt en kan op elk moment worden uitgerold. Dus niemand werkt rechtstreeks op main. Elke taak krijgt een nieuwe branch:
git switch -c feat/priority-score
Switched to a new branch 'feat/priority-score'Hier wordt triage.py geschreven:
def priority(text: str) -> str:
urgent_words = ("outage", "down", "breach")
if any(word in text for word in urgent_words):
return "high"
return "normal"git add triage.py
git commit -m "feat(triage): add keyword-based priority"
[feat/priority-score ded4456] feat(triage): add keyword-based priority
1 file changed, 5 insertions(+)
create mode 100644 triage.pyBranchnamen volgen dezelfde stijl: feat/..., fix/..., docs/... — aan de lijst zie je in één oogopslag wat elke branch is.
Een woord over de branchingstrategie. Vroeger hielden veel teams meerdere langlevende branches bij — develop, release, hotfix — en verplaatsten ze code daartussen volgens ingewikkelde regels. De meeste teams kiezen nu het eenvoudigere pad: één main, en kleine, kortlevende feature-branches daarvan af, die binnen een dag of twee worden gereviewd en teruggemerged. Hoe langer een branch apart staat, hoe verder main opschuift, en hoe groter de conflicten wanneer hij terugkomt. Deze cursus volgt het eenvoudige pad.
Remotes: push en pull
Tot nu toe staat alles op jouw machine. Om met een team te delen heb je een remote repository nodig — meestal op GitHub of een vergelijkbare dienst. Als je daar een repository aanmaakt, krijg je een adres, en dan schrijf je:
git remote add origin https://github.com/<your-account>/ticket-triage.gitDe voorbeelden in dit hoofdstuk zijn geverifieerd zonder online dienst: een “bare” repository werd in een naastgelegen map aangemaakt en als remote gebruikt. Voor Git zijn die twee hetzelfde, en zo kun je ook op je eigen machine oefenen:
git init --bare ../remote.git
git remote add origin ../remote.gitDe eerste push heeft -u nodig om aan te geven aan welke remote-branch deze branch wordt gekoppeld:
git push -u origin main
branch 'main' set up to track 'origin/main'.
To ../remote.git
* [new branch] main -> mainDe feature-branch gaat op dezelfde manier:
git push -u origin feat/priority-score
branch 'feat/priority-score' set up to track 'origin/feat/priority-score'.
To ../remote.git
* [new branch] feat/priority-score -> feat/priority-scoreNu kloont een collega de repository:
git clone remote.git teammate
Cloning into 'teammate'...
done.Hun map heeft README.md, main.py, pyproject.toml, uv.lock (en de verborgen bestanden) — maar geen .venv en geen .env. Precies zoals het hoort: zij draaien uv sync om hun eigen omgeving te bouwen, en maken hun eigen .env aan vanuit .env.example.
Nadat de feature in main is gemerged (in echt werk gebeurt dat wanneer de Pull Request wordt gemerged — zie hieronder), haalt de collega de nieuwe stand op:
git pull
Updating 0f78e59..5f802b4
Fast-forward
triage.py | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 triage.py(De echte uitvoer begint met een From ...-regel met het volledige pad van de remote; die is hier weggelaten.) Fast-forward betekent dat de collega geen eigen nieuwe commits had, dus Git heeft hun main gewoon vooruitgeschoven.
Issues en Pull Requests
Op een bedrijf begint werk meestal met een issue — een genummerde beschrijving van een probleem, geschreven op de code-hostingdienst. Bijvoorbeeld: #12 — Hoofdletterzoekwoorden worden niet herkend (tickets met “OUTAGE” komen binnen als normaal).
Daarna verloopt de cyclus zo:
- Een branch vanaf
main:git switch -c fix/uppercase-keywords - Kleine, betekenisvolle commits
- De branch pushen
- Op de website van de dienst, of vanaf de commandoregel, open je een Pull Request (PR) — een verzoek om de wijzigingen van jouw branch in
mainop te nemen - De reviewer plaatst opmerkingen, en jij antwoordt met nieuwe commits op dezelfde branch
- Zodra de wijziging is goedgekeurd en de geautomatiseerde controles (CI) slagen, wordt de PR gemerged en de branch verwijderd
Met GitHubs commandoregeltool gh ziet stap 4 er zo uit (de uitvoer hangt af van je account en repository, dus die staat hier niet):
gh pr create --base main --title "fix(triage): detect uppercase keywords" --body-file PR.mdEen nette PR-beschrijving (PR.md) beantwoordt de drie vragen van de reviewer — wat, waarom, en hoe er getest is:
## What
Lowercase ticket text before matching urgent keywords.
## Why
Tickets written in capitals ("OUTAGE") were scored as normal.
Closes #12
## How I tested
priority("Service DOWN") returned "normal" on main, "high" on this branch
uv run check_commits.py -> 0 of 2 commit messages need workDe regel Closes #12 is een klein gemak: wanneer de PR in de standaardbranch wordt gemerged, sluit de dienst issue #12 vanzelf.
Hoe mentoren reviewen
Elke praktijkopdracht in deze cursus wordt bekeken met de ogen van een reviewer, en op een bedrijf komen dan deze vragen naar voren:
- Is de PR klein en heeft hij één doel? Eén feature, één bug, of één refactor — niet alle drie samen. Boven een paar honderd regels daalt de kwaliteit van de review, omdat mensen hun aandacht niet over een lange diff kunnen vasthouden.
- Lezen de commitberichten als een geschiedenis? Een geschiedenis vol
wipenfixis voor niemand nuttig. - Wat is erin gekomen dat er niet in had mogen komen?
.venv,.env,__pycache__, grote databestanden — als een van deze in de diff verschijnt, gaat de PR terug. - Wat hoort erin te staan? Als er een nieuw pakket is toegevoegd, moeten de wijzigingen in
pyproject.tomlenuv.lockin de diff staan. - Staat in de beschrijving hoe er is getest?
Als het bericht van de laatste commit fout is, kun je het aanpassen voordat je pusht:
git commit --amend -m "docs: describe the project in README"
git log --oneline -1
a18cc1a docs: describe the project in READMEEen commit wijzigen nadat hij is gepusht betekent de geschiedenis herschrijven, en dat zorgt voor problemen bij collega's; in dat geval is een nieuwe commit beter. Veel teams mergen PR's met “squash” — alle commits van de branch worden samengevoegd tot één, en de titel van de PR wordt het bericht. Dan moet ook de PR-titel dezelfde stijl volgen.
Een compleet voorbeeld
Een van de taken van de reviewer kan aan een machine worden overgelaten: controleren of elk commitbericht op de branch de regels volgt. Teams draaien zulke controles in CI, zodat het resultaat verschijnt zodra een PR wordt geopend. De check_commits.py hieronder doet precies dat — hij draait git log en leest alleen de commits van de huidige branch, de commits die niet op main staan:
import re
import subprocess
import sys
ALLOWED_TYPES = {"feat", "fix", "docs", "refactor", "test", "chore"}
SUBJECT = re.compile(r"^(?P<type>[a-z]+)(\([a-z0-9-]+\))?: (?P<summary>.+)$")
MAX_LENGTH = 72
VAGUE = {"wip", "fix", "update", "changes", "stuff", "misc"}
def problems_in(subject: str) -> list[str]:
found = []
if subject.strip().lower() in VAGUE:
found.append("too vague for a reviewer")
if len(subject) > MAX_LENGTH:
found.append(f"longer than {MAX_LENGTH} characters ({len(subject)})")
match = SUBJECT.match(subject)
if match is None:
found.append("missing 'type: summary' prefix")
elif match["type"] not in ALLOWED_TYPES:
found.append(f"unknown type '{match['type']}'")
if subject.endswith("."):
found.append("ends with a period")
return found
def branch_subjects(base: str) -> list[str]:
result = subprocess.run(
["git", "log", "--reverse", "--format=%s", f"{base}..HEAD"],
capture_output=True,
encoding="utf-8",
)
if result.returncode != 0:
raise SystemExit(f"git log failed: {result.stderr.strip()}")
return result.stdout.splitlines()
def main() -> int:
subjects = branch_subjects("main")
failures = 0
for subject in subjects:
problems = problems_in(subject)
print(("ok " if not problems else "FAIL") + " " + subject)
for problem in problems:
print(f" - {problem}")
failures += 1 if problems else 0
print(f"{failures} of {len(subjects)} commit messages need work")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())Om het te testen is een branch vanaf main gemaakt met vijf commits — sommige met bewust slechte berichten. (--allow-empty maakt een commit zonder een bestand te wijzigen; dat is genoeg om berichten te controleren. In echt werk bevat elke commit een codewijziging.)
git switch -c fix/uppercase-keywords
git commit --allow-empty -m "wip"
git commit --allow-empty -m "Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals"
git commit --allow-empty -m "fix(triage): lowercase text before matching."
git commit --allow-empty -m "feature: add tests"
git commit --allow-empty -m "test(triage): cover uppercase keywords"Draai daarna, vanuit de repository:
uv run check_commits.pyFAIL wip
- too vague for a reviewer
- missing 'type: summary' prefix
FAIL Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
- longer than 72 characters (84)
- missing 'type: summary' prefix
FAIL fix(triage): lowercase text before matching.
- ends with a period
FAIL feature: add tests
- unknown type 'feature'
ok test(triage): cover uppercase keywords
4 of 5 commit messages need workDe exitcode van het programma is 1, omdat minstens één bericht is afgekeurd. Als alles in orde is, is hij 0.
De keuzes in dit script:
main..HEAD — deze range met twee punten betekent “commits in HEAD die niet in main staan”. Met andere woorden: alleen het eigen werk van deze branch. De hele geschiedenis controleren zou je PR laten afkeuren om commits van een jaar oud die door anderen zijn geschreven. --reverse zet ze in volgorde, de oudste eerst, de volgorde waarin het werk is gedaan.
--format=%s — %s betekent alleen de eerste regel van de commit (het onderwerp). Geen hash, datum of auteur — er valt dus niets te parsen; elke regel is één bericht.
encoding="utf-8" — met text=True gebruikt Python de standaardcodering van het besturingssysteem, die op Windows vaak geen UTF-8 is. Als iemand een commitbericht in het Bengaals of met een emoji schrijft, kan de uitvoer breken. Door de codering op te geven, verdwijnt die onzekerheid.
Een eigen bericht in plaats van check=True — wanneer git faalt (uitgevoerd buiten een repository, of zonder een branch met de naam main), toont het script de eigen foutmelding van git in plaats van een lange Python-traceback. Een tekst meegeven aan SystemExit zorgt dat Python die naar stderr schrijft en stopt met exitcode 1.
De exitcode — CI leest de uitvoer niet; het kijkt alleen of het programma met 0 is geëindigd. Daarom geeft main() een getal terug, en sys.exit geeft dat door aan het besturingssysteem.
Wanneer het breekt
Author identity unknown gevolgd door fatal: unable to auto-detect email address Git weet niet op wiens naam de commit moet komen. Voer de twee git config --global user.name ...- en user.email ...-commando's uit het begin van het hoofdstuk één keer uit.
error: src refspec main does not match any Er zijn nog geen commits, dus de branch main bestaat eigenlijk niet — er is niets om te pushen. Doe eerst git add en git commit, en push daarna. Dezelfde melding verschijnt als de branch eigenlijk master heet; controleer op welke branch je staat met git branch.
fatal: The current branch feat/x has no upstream branch. Een nieuwe branch wordt voor de eerste keer gepusht, en Git weet niet naar welke remote-branch hij moet. De melding geeft de oplossing zelf: git push --set-upstream origin feat/x (of kort git push -u origin feat/x). Daarna volstaat gewoon git push.
! [rejected] main -> main (fetch first) Iemand anders heeft sinds je laatste fetch naar dezelfde branch gepusht. Integreer eerst hun werk met git pull, en push daarna. Los het niet op met git push --force — dat wist het werk van je collega.
warning: in the working copy of 'main.py', LF will be replaced by CRLF the next time Git touches it Op Windows is dit een waarschuwing, geen fout. Windows en macOS/Linux beëindigen regels met verschillende tekens, en Git laat weten dat het die zal gelijktrekken. De commit is gewoon gelukt.
.env is per ongeluk gecommit Voer git rm --cached .env uit — dat houdt het bestand op schijf en haalt het alleen uit Git's bijhouding (de uitvoer is rm '.env') — voeg daarna .env toe aan .gitignore en commit. Maar onthoud: de sleutel staat nog in de eerdere commit. Als hij is gepusht, is er geen veilige optie behalve de sleutel intrekken en een nieuwe aanmaken.
Stap 4 van 6 — Voorspellen
Test je begrip
Drie berichten worden gecontroleerd tegen het SUBJECT-patroon van het hoofdstuk. Wat wordt geprint?
import re
SUBJECT = re.compile(r"^(?P<type>[a-z]+)(\([a-z0-9-]+\))?: (?P<summary>.+)$")
for subject in ["fix: handle empty input", "Fix: handle empty input", "fix:handle empty input"]:
print(SUBJECT.match(subject) is not None)- A`True`, `False`, `True`
- B`True`, `True`, `True`
- C`True`, `True`, `False`
- D`True`, `False`, `False`
Een collega heeft deze functie in check_commits.py "gerepareerd". Nu is CI groen, zelfs op een branch met wip- en feature: add tests-commits, en de uitvoer is 0 of 0 commit messages need work. Waar zit de bug?
import subprocess
def branch_subjects(base: str) -> list[str]:
result = subprocess.run(
["git", "log", "--reverse", "--format=%s", f"HEAD..{base}"],
capture_output=True,
encoding="utf-8",
)
if result.returncode != 0:
raise SystemExit(f"git log failed: {result.stderr.strip()}")
return result.stdout.splitlines()- ADe range staat omgekeerd: `HEAD..main` betekent "in `main` maar niet op deze branch", dus geen van de eigen commits van de branch komt terug; het moet `main..HEAD` zijn
- B`--reverse` laat commits weg; haal het weg en ze verschijnen
- CDe controle op `returncode` gooit elke commit weg; het heeft `check=True` nodig
- DMet `encoding="utf-8"` geeft `git` geen uitvoer; het heeft `text=True` nodig
Een PR-beschrijving bevat Closes #12. Wat doet dat?
- AWanneer de PR in de standaardbranch wordt gemerged, sluit de code-hostingdienst issue #12 zelf en houdt de twee gekoppeld
- BIssue #12 wordt gesloten zodra de PR wordt geopend
- CNiets — het is alleen een notitie voor de reviewer; geen machine leest het
- DGit verwijdert commit nummer 12 uit de geschiedenis
Spelen hoort bij Pro
+30 XP wacht op je
Speel dit hoofdstuk met Pro
Lezen is gratis en het hele hoofdstuk is open. De eerste 5 hoofdstukken van elke gratis cursus speel je gratis. Vanaf hoofdstuk 6 horen vragen beantwoorden, XP verdienen en je reeks laten groeien bij Pro.
Ontgrendel spelen met ProJouw beurt
Maak op je eigen machine een project aan en doorloop de hele cyclus één keer:
- Maak het project aan met
uv init my-triage --python 3.12, voeg.envtoe aan.gitignore, bewaar een.env.example-bestand (met alleenLLM_API_KEY=), controleer datgit status --shortgeen.envof.venvtoont, en maak de eerste commit - Voeg een bare repository toe als remote (
git init --bare ../my-triage-remote.git) en pushmain - Maak een branch met de naam
feat/priority-scoreen voeg detriage.pyencheck_commits.pyvan dit hoofdstuk toe in precies drie commits, elk volgens de regels; push de branch - Schrijf een
PR.md— Wat, Waarom, Hoe ik getest heb — en open, als je een account op een code-hostingdienst hebt, een echte PR daar met deze beschrijving
Op de branch moet uv run check_commits.py eindigen met precies deze regel, voorafgegaan door drie ok-regels:
0 of 3 commit messages need workMaak daarna met opzet een slechte commit (git commit --allow-empty -m "wip"), draai het script opnieuw om te zien welke twee problemen worden gevangen, en repareer de commit met git commit --amend -m "...". Of probeer dit: vervang main..HEAD door HEAD..main in het script, kijk wat er wordt afgedrukt, en bedenk waarom dat de gevaarlijkste soort fout is voor een CI-controle.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Speel met Pro