Git, branches et Pull Requests — comment travaillent les équipes
De git init à push et pull, garder .venv et .env hors du dépôt avec .gitignore, petites branches de fonctionnalité, messages de commit clairs et Pull Requests — plus un outil Python qui lit git log pour vérifier les commits.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Un ingénieur IA a passé une semaine sur un projet de tri de tickets : il a modifié le prompt, allongé la liste de mots-clés, réécrit une fonction. Le vendredi, les résultats sont moins bons que le lundi. Quel changement est responsable ? Le dossier contient triage.py, triage_v2.py, triage_final.py et triage_final_REAL.py — et personne ne sait ce qui a changé dans chacun.
Pendant ce temps, un collègue qui modifiait le même fichier dans un dossier partagé a écrasé son travail. Et il y a un mois, quelqu'un a collé une clé d'API directement dans le code pour un test rapide ; par ce dossier partagé, elle est arrivée chez dix autres personnes.
Ces trois problèmes ont une seule réponse, que presque toutes les équipes logicielles du monde utilisent : garder l'historique avec Git, faire chaque travail sur sa propre branche, et, avant que quoi que ce soit n'atteigne le code principal, faire relire le travail par quelqu'un d'autre dans une Pull Request. Ce chapitre vous apprend tout ce cycle — et se termine par l'écriture d'un outil Python qui automatise l'une des tâches du relecteur.
À la fin de ce chapitre vous pourrez
- Garder et partager l'historique d'un projet avec
git init,add,commit,pushetpull - Garder
.venvet.envhors du dépôt avec.gitignore, et savoir quoi faire quand l'un d'eux a été commité par erreur - Travailler sur de petites branches de fonctionnalité, de courte durée, et ouvrir une Pull Request claire
- Écrire des messages de commit comme les relecteurs les lisent
- Écrire un outil Python qui lit
git logavecsubprocesset vérifie les messages de commit
Ce qu'il vous faut d'abord : Le parcours ingénieur IA, et les environnements de développement avec uv — en particulier uv init et uv run.
Configuration initiale
Git enregistre qui a fait chaque commit. Donc, la première fois, vous vous présentez une seule fois — et il vaut la peine de demander à Git de nommer main la branche principale des nouveaux dépôts, car c'est ce que la plupart des équipes utilisent aujourd'hui :
git config --global user.name "Nadia Karim"
git config --global user.email "[email protected]"
git config --global init.defaultBranch mainUtilisez votre propre nom et l'adresse e-mail avec laquelle votre compte de l'hébergeur de code est enregistré. Sans la dernière ligne, les anciennes versions de Git appellent la branche principale master — ce qui fonctionne, mais ne correspond pas au reste de l'équipe.
Trois endroits : le dossier, la zone de staging et l'historique
Dans le chapitre précédent, uv init a créé lui-même un dépôt Git. L'exemple de ce chapitre commence de la même façon :
uv init ticket-triage --python 3.12
cd ticket-triageGit raisonne en trois endroits, et chaque commande déplace quelque chose de l'un à l'autre :
- Le répertoire de travail — votre dossier, où vous modifiez les fichiers
- La zone de staging — la liste de ce qui entrera dans le prochain commit ;
git addplace les éléments ici - L'historique — la chaîne des commits ;
git committransforme la zone de staging en un commit permanent
Pourquoi une étape de staging séparée ? Parce qu'au milieu d'une tâche, vous avez pu modifier cinq fichiers, dont deux corrigent un bug et trois ajoutent une fonctionnalité. Le staging permet de les répartir en deux commits distincts et significatifs — ce qui a beaucoup de valeur pour un relecteur.
La commande pour voir où en sont les choses est git status, ou, en forme courte, git status --short.
.gitignore : ce qui n'entre jamais dans le dépôt
Les projets d'IA ont presque toujours un fichier .env qui contient des clés d'API. Imaginons que le projet reçoive un fichier de ce genre (sk-... n'est ici qu'un exemple, pas une vraie clé) :
LLM_API_KEY=sk-...Le .gitignore que uv init crée contient .venv, mais pas .env. La première chose à faire est donc de l'ajouter à la fin :
# Secrets
.envLancez maintenant uv run main.py une fois (pour que .venv et uv.lock soient créés), puis vérifiez l'état :
git status --short
?? .gitignore
?? .python-version
?? README.md
?? main.py
?? pyproject.toml
?? uv.lock?? signifie que Git voit le fichier mais ne le suit pas encore. Le point important est ce qui n'est pas dans la liste : ni .venv, ni .env. Les deux sont dans le dossier, mais Git fait comme s'il ne les voyait pas. (.venv bénéficie en réalité de deux niveaux de protection : quand uv crée l'environnement, il dépose aussi un minuscule .gitignore contenant seulement * à l'intérieur.) Et uv.lock est bien là — il doit être commité, car c'est ce qui permet à vos collègues d'obtenir exactement le même environnement.
Alors, comment vos collègues sauront-ils quelles clés leur faut-il ? La méthode habituelle consiste à committer un fichier .env.example qui contient les noms des variables mais pas leurs valeurs. Un nouvel arrivant le copie en .env et y renseigne ses propres clés.
Un secret qui a été commité n'est pas effacé en le supprimant dans le commit suivant. Le rôle même de Git est de se souvenir de chaque état antérieur : la clé reste donc dans le commit précédent pour toujours — n'importe qui peut l'extraire avecgit show. Une fois qu'une clé a été poussée vers un remote, la seule démarche sûre est d'aller sur le service auquel la clé appartient, de la révoquer et d'en créer une nouvelle. C'est pourquoi.envdoit figurer dans.gitignoreavant même que le fichier ne soit créé.
Le premier commit, et de bons messages de commit
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 est l'identifiant du commit (le début de son hash) — il sera différent sur votre machine, car l'heure et l'auteur entrent dans le calcul.
Un message de commit s'écrit pour un lecteur futur — vous dans six mois, et le relecteur d'aujourd'hui. Beaucoup d'équipes suivent un style commun appelé Conventional Commits :
type(scope): short summary in the imperative- type indique le genre de changement :
feat(une nouvelle capacité),fix(une correction de bug),docs,refactor,test,chore(maintenance) - scope est facultatif, entre parenthèses, et désigne la partie du projet concernée :
feat(triage): ... - summary est à l'impératif (« ajoute », « corrige », « supprime » — pas « ajouté »), tient en 72 caractères au maximum, et ne se termine pas par un point
Côte à côte, la différence saute aux yeux :
wip
fixed stuff
Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
fix(triage): lowercase ticket text before keyword matchingLes trois premiers ne provoquent aucune erreur — Git accepte n'importe quel message. Mais dans git log, les deux premiers ne disent rien, et le troisième est si long qu'il est coupé dans une liste d'une seule ligne. En lisant le dernier, un relecteur sait ce qui a changé et où. Si le « pourquoi » demande plus de place, il va dans le corps du message, après une ligne vide.
Branches : chaque travail dans sa propre pièce
La branche main est ce que l'équipe a de plus précieux : ce qui s'y trouve fonctionne et peut être déployé à tout moment. Personne ne travaille donc directement sur main. Chaque tâche obtient une nouvelle branche :
git switch -c feat/priority-score
Switched to a new branch 'feat/priority-score'Voici le code de triage.py :
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.pyLes noms de branche suivent le même style : feat/..., fix/..., docs/... — la liste indique d'un coup d'œil ce que chacune contient.
Un mot sur les stratégies de branchement. Autrefois, beaucoup d'équipes gardaient plusieurs branches de longue durée — develop, release, hotfix — et y faisaient passer le code selon des règles élaborées. Aujourd'hui, la plupart des équipes choisissent plus simple : une seule main, et de petites branches de fonctionnalité, de courte durée, créées à partir d'elle, relues et fusionnées en un jour ou deux. Plus une branche reste à l'écart, plus main avance, et plus les conflits sont lourds à son retour. Ce cours suit la voie simple.
Remotes : push et pull
Jusqu'ici, tout se trouve sur votre machine. Pour partager avec une équipe, il faut un dépôt distant (remote) — en général sur GitHub ou un service similaire. Créer un dépôt là-bas vous donne une adresse, et vous écrivez :
git remote add origin https://github.com/<your-account>/ticket-triage.gitLes sorties de ce chapitre ont été vérifiées sans aucun service en ligne : un dépôt « nu » (bare) a été créé dans un dossier voisin et utilisé comme remote. Pour Git, les deux sont la même chose, et vous pouvez vous entraîner ainsi sur votre propre machine :
git init --bare ../remote.git
git remote add origin ../remote.gitLe premier push a besoin de -u pour indiquer à quelle branche distante cette branche est liée :
git push -u origin main
branch 'main' set up to track 'origin/main'.
To ../remote.git
* [new branch] main -> mainLa branche de fonctionnalité suit le même chemin :
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-scoreMaintenant, un collègue clone le dépôt :
git clone remote.git teammate
Cloning into 'teammate'...
done.Son dossier contient README.md, main.py, pyproject.toml, uv.lock (et les fichiers cachés) — mais ni .venv, ni .env. C'est exactement ce qu'il faut : il lance uv sync pour construire son propre environnement, et crée son propre .env à partir de .env.example.
Une fois la fonctionnalité fusionnée dans main (dans un vrai travail, cela se produit lorsque la Pull Request est fusionnée — voir plus bas), le collègue récupère le nouvel état :
git pull
Updating 0f78e59..5f802b4
Fast-forward
triage.py | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 triage.py(La sortie réelle commence par une ligne From ... qui donne le chemin complet du remote ; elle est omise ici.) Fast-forward signifie que le collègue n'avait pas de commits à lui, et que Git a donc simplement fait avancer sa main.
Issues et Pull Requests
Dans une entreprise, le travail commence généralement par une issue — une description numérotée d'un problème, rédigée sur le service d'hébergement de code. Par exemple : #12 — Les mots-clés en majuscules ne sont pas détectés (les tickets disant « OUTAGE » sont classés comme normaux).
Le cycle se déroule ensuite ainsi :
- Une branche à partir de
main:git switch -c fix/uppercase-keywords - De petits commits, significatifs
- Le push de la branche
- Sur le site du service, ou depuis la ligne de commande, ouvrir une Pull Request (PR) — une demande qui dit « intégrer les changements de ma branche dans
main» - Le relecteur commente, et vous répondez par de nouveaux commits sur la même branche
- Une fois approuvée et les vérifications automatiques (CI) réussies, la PR est fusionnée et la branche supprimée
Avec l'outil en ligne de commande de GitHub, gh, l'étape 4 ressemble à ceci (sa sortie dépend de votre compte et de votre dépôt, elle n'est donc pas affichée ici) :
gh pr create --base main --title "fix(triage): detect uppercase keywords" --body-file PR.mdUne description de PR claire (PR.md) répond aux trois questions du relecteur — quoi, pourquoi et comment cela a été testé :
## 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 workLa ligne Closes #12 est une petite commodité : lorsque la PR est fusionnée dans la branche par défaut, le service ferme seul l'issue n° 12.
Comment les mentors relisent
Chaque tâche pratique de ce cours sera examinée du point de vue d'un relecteur, et en entreprise, ce sont les questions qui reviennent :
- La PR est-elle petite et d'un seul objectif ? Une fonctionnalité, un bug ou un refactoring — pas les trois à la fois. Au-delà de quelques centaines de lignes, la qualité de la relecture baisse, car on n'arrive plus à garder son attention sur un long diff.
- Les messages de commit se lisent-ils comme un historique ? Un historique rempli de
wipet defixn'est utile à personne. - Qu'est-ce qui est entré alors que cela n'aurait pas dû ?
.venv,.env,__pycache__, les gros fichiers de données — si l'un de ces éléments apparaît dans le diff, la PR est renvoyée. - Ce qui devrait être là y est-il ? Si un nouveau paquet a été ajouté, les modifications de
pyproject.tomlet deuv.lockdoivent figurer dans le diff. - La description indique-t-elle comment cela a été testé ?
Si le message du dernier commit est incorrect, vous pouvez le changer avant le push :
git commit --amend -m "docs: describe the project in README"
git log --oneline -1
a18cc1a docs: describe the project in READMEModifier un commit après l'avoir poussé revient à réécrire l'historique, ce qui pose problème aux collègues ; dans ce cas, un nouveau commit vaut mieux. Beaucoup d'équipes fusionnent les PR avec « squash » — tous les commits de la branche sont combinés en un seul, et le titre de la PR devient son message. Le titre de la PR doit alors suivre le même style.
Un exemple complet
L'une des tâches du relecteur peut être confiée à une machine : vérifier que chaque message de commit de la branche respecte les règles. Les équipes lancent ce genre de contrôle en CI, si bien que le résultat apparaît dès qu'une PR est ouverte. Le script check_commits.py ci-dessous fait exactement cela — il lance git log et ne lit que les commits de la branche courante, ceux qui ne sont pas sur main :
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())Pour le tester, une branche a été créée à partir de main avec cinq commits — dont certains avec des messages volontairement mauvais. (--allow-empty crée un commit sans modifier aucun fichier ; cela suffit pour vérifier les messages. Dans un vrai travail, chaque commit contient une modification de code.)
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"Ensuite, depuis l'intérieur du dépôt :
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 workLe code de sortie du programme est 1, car au moins un message a échoué. Si tout est correct, il vaut 0.
Les choix de ce script :
main..HEAD — cette plage à deux points signifie « les commits de HEAD qui ne sont pas dans main ». Autrement dit, seulement le travail propre à cette branche. Vérifier tout l'historique ferait échouer votre PR à cause de commits vieux d'un an, écrits par d'autres personnes. --reverse les classe du plus ancien au plus récent, dans l'ordre où le travail a été fait.
--format=%s — %s ne désigne que la première ligne du commit (le sujet). Ni hash, ni date, ni auteur — il n'y a donc rien à analyser ; chaque ligne est un message.
encoding="utf-8" — avec text=True, Python utilise l'encodage par défaut du système d'exploitation, qui sous Windows n'est souvent pas l'UTF-8. Si quelqu'un écrit un message de commit en bengali ou avec un emoji, l'affichage peut se casser. Indiquer l'encodage supprime cette incertitude.
Son propre message au lieu de check=True — quand git échoue (lancé hors d'un dépôt, ou sans branche appelée main), le script affiche le message d'erreur de git au lieu d'une longue trace d'erreur Python. Passer une chaîne à SystemExit fait afficher celle-ci sur la sortie d'erreur et arrêter le programme avec le code de sortie 1.
Le code de sortie — la CI ne lit pas la sortie ; elle vérifie seulement si le programme s'est terminé avec 0. Donc main() renvoie un nombre, et sys.exit le transmet au système d'exploitation.
Quand ça casse
Author identity unknown suivi de fatal: unable to auto-detect email address Git ne sait pas à qui attribuer le commit. Lancez une fois les deux commandes git config --global user.name ... et user.email ... du début du chapitre.
error: src refspec main does not match any Il n'y a pas encore de commit, donc la branche main n'existe pas vraiment — il n'y a rien à pousser. Faites d'abord git add et git commit, puis le push. Le même message apparaît si la branche s'appelle en fait master ; vérifiez sur quelle branche vous êtes avec git branch.
fatal: The current branch feat/x has no upstream branch. Une nouvelle branche est poussée pour la première fois, et Git ne sait pas vers quelle branche distante l'envoyer. Le message donne la solution : git push --set-upstream origin feat/x (ou git push -u origin feat/x en forme courte). Ensuite, un simple git push suffit.
! [rejected] main -> main (fetch first) Quelqu'un d'autre a poussé sur la même branche depuis votre dernière récupération. Commencez par intégrer son travail avec git pull, puis poussez. Ne le « réparez » pas avec git push --force — cela efface le travail de votre collègue.
warning: in the working copy of 'main.py', LF will be replaced by CRLF the next time Git touches it Sous Windows, c'est un avertissement, pas une erreur. Windows, macOS et Linux terminent les lignes avec des caractères différents, et Git vous indique qu'il va les harmoniser. Le commit a bien été effectué.
.env a été commité par erreur Lancez git rm --cached .env — cela garde le fichier sur le disque et le retire seulement du suivi de Git (la sortie est rm '.env') — puis ajoutez .env à .gitignore et committez. Mais souvenez-vous que la clé est toujours dans le commit précédent. Si elle a été poussée, il n'existe pas d'autre option sûre que de révoquer la clé et d'en créer une nouvelle.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Trois messages sont vérifiés avec le motif SUBJECT du chapitre. Qu'est-ce qui s'affiche ?
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`
Un collègue a « corrigé » cette fonction dans check_commits.py. Maintenant la CI est au vert, même sur une branche avec des commits wip et feature: add tests, et la sortie est 0 of 0 commit messages need work. Où est le 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()- ALa plage est inversée : `HEAD..main` signifie « dans `main` mais pas sur cette branche », donc aucun commit propre à la branche n'apparaît ; il faut `main..HEAD`
- B`--reverse` supprime des commits ; le retirer les fera apparaître
- CLa vérification de `returncode` écarte chaque commit ; il lui faut `check=True`
- DAvec `encoding="utf-8"`, `git` ne produit aucune sortie ; il lui faut `text=True`
Une description de PR contient Closes #12. Que fait cette ligne ?
- ALorsque la PR est fusionnée dans la branche par défaut, le service d'hébergement ferme seul l'issue n° 12 et garde les deux liées
- BL'issue n° 12 est fermée dès que la PR est ouverte
- CRien — c'est une simple note pour le relecteur ; aucune machine ne la lit
- DGit supprime le commit numéro 12 de l'historique
Jouer fait partie de Pro
+30 XP vous attendent
Jouez ce chapitre avec Pro
La lecture est gratuite et tout le chapitre est ouvert. Les 5 premiers chapitres de chaque cours gratuit se jouent gratuitement. À partir du chapitre 6, répondre aux questions, gagner des XP et faire grandir votre série fait partie de Pro.
Débloquer le jeu avec ProÀ vous de jouer
Créez un projet sur votre propre machine et parcourez tout le cycle une fois :
- Créez le projet avec
uv init my-triage --python 3.12, ajoutez.envà.gitignore, gardez un fichier.env.example(contenant seulementLLM_API_KEY=), vérifiez quegit status --shortn'affiche ni.envni.venv, puis faites le premier commit - Ajoutez un dépôt nu comme remote (
git init --bare ../my-triage-remote.git) et poussezmain - Créez une branche appelée
feat/priority-score, ajoutez letriage.pyet lecheck_commits.pyde ce chapitre en exactement trois commits, chacun respectant les règles ; poussez la branche - Écrivez un
PR.mdavec les sections## What,## Whyet## How I tested, et, si vous avez un compte sur un hébergeur de code, ouvrez une vraie PR avec cette description
Sur la branche, uv run check_commits.py doit se terminer exactement par cette ligne, précédée de trois lignes ok :
0 of 3 commit messages need workFaites ensuite volontairement un mauvais commit (git commit --allow-empty -m "wip"), relancez le script pour voir quels deux problèmes sont détectés, et corrigez-le avec git commit --amend -m "...". Ou essayez ceci : remplacez main..HEAD par HEAD..main dans le script, observez ce qu'il affiche, et trouvez pourquoi c'est le type d'erreur le plus dangereux pour un contrôle CI.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Jouer avec Pro