Aller au contenu principal
genie-logicielLa gestion de versions avec Git

La gestion de versions avec Git

Ce que ce chapitre apporte

  • Expliquer ce qu'apporte un gestionnaire de versions et ce que la copie de dossiers ne permet pas.
  • Distinguer les trois zones d'un dépôt Git et le rôle de chacune.
  • Décrire ce qu'est un commit et pourquoi l'historique forme un graphe.
  • Employer les branches et expliquer ce que calcule une fusion.
  • Reconnaître un conflit, comprendre son origine et le résoudre.
  • Distinguer un dépôt local d'un dépôt distant, et les opérations associées.
  • Choisir un message de commit et un découpage utiles.
Où on va
Dès que deux personnes modifient le même fichier, il faut un moyen de savoir qui a changé quoi, de revenir à un état antérieur, et de réunir des travaux menés en parallèle. Les dossiers nommés projet_v2_final_corrigé ne répondent à aucune de ces trois questions. Ce chapitre présente le modèle de Git, qui y répond, et les opérations qu'il faut comprendre avant de retenir des commandes.

Ce que résout un gestionnaire de versions

Définition

Un gestionnaire de versions enregistre l'évolution d'un ensemble de fichiers au cours du temps, en conservant chaque état, son auteur, sa date et sa justification.

Trois besoins le motivent.

Le premier est la traçabilité. Une ligne de code étrange se comprend souvent en retrouvant quand elle a été écrite et pourquoi. Sans historique, l'information est perdue.

Le deuxième est le retour en arrière. Une modification qui casse la production doit pouvoir être annulée en quelques secondes, sans reconstituer l'état antérieur de mémoire.

Le troisième est le travail parallèle. Plusieurs personnes modifient les mêmes fichiers ; il faut réunir leurs travaux sans que l'un écrase l'autre, et savoir quand ce n'est pas possible automatiquement.

Centralisé et distribué
Les systèmes centralisés comme Subversion conservent l'historique sur un serveur unique. Chaque opération demande une connexion, et la panne du serveur bloque tout le monde.
Git est distribué : chaque poste possède une copie complète de l'historique. On peut consulter, valider et créer des branches hors ligne. Le serveur ne sert qu'à la synchronisation, et il n'est pas la seule copie.

Les trois zones

C'est le point qui déroute au départ, et tout le reste en découle.

ZoneContenuCommande pour y passer
Répertoire de travailles fichiers tels qu'on les éditemodification directe
Index, ou zone d'attentece qui entrera dans le prochain commitgit add
Dépôt locall'historique des commitsgit commit
À quoi sert l'index
Il permet de choisir ce qu'on enregistre, indépendamment de ce qu'on a modifié. Si une session de travail a corrigé un défaut et, au passage, renommé trois variables sans rapport, l'index permet de composer deux commits distincts à partir d'un même état du répertoire.
Sans lui, un commit contiendrait toujours l'intégralité des modifications en cours, et l'historique deviendrait illisible.
Terminal7 commandes
$git status# ce qui est modifié, indexé, ou ignoré
$git add fichier.cs# place ce fichier dans l'index
$git add -p# choisit interactivement les portions à indexer
$git commit -m "message" # enregistre l'index dans l'historique
$git log --oneline --graph# relit l'historique sous forme de graphe
$git diff# travail contre index
$git diff --staged# index contre dernier commit

Le commit et le graphe

Définition

Un commit enregistre un état complet du projet, accompagné de son auteur, de sa date, d'un message, et d'une ou plusieurs références vers ses parents.

Ce sont les parents qui font la structure. Un commit ordinaire a un parent, ce qui donne une chaîne. Un commit de fusion en a deux, ce qui donne un graphe orienté sans cycle. L'historique n'est donc pas une liste, et beaucoup de confusions viennent de là.

L'identifiant d'un commit est une empreinte calculée sur son contenu, ses parents et ses métadonnées. Deux conséquences : l'identifiant ne peut pas être choisi, et modifier un commit ancien change tous ses descendants, puisque leur empreinte dépend de la sienne.

Les branches

Définition

Une branche est un simple pointeur nommé vers un commit. Créer une branche ne copie rien.

Cette légèreté explique l'usage courant : on crée une branche par correctif ou par fonctionnalité, on y travaille, on la fusionne, on la supprime. Le pointeur avance automatiquement à chaque commit.

Terminal5 commandes
$git branch# liste les branches
$git switch -c correctif-log# crée une branche et s'y place
$git switch main# revient sur la principale
$git merge correctif-log# fusionne la branche dans la branche courante
$git branch -d correctif-log# supprime le pointeur devenu inutile

Ce que calcule une fusion

Fusionner deux branches suppose de trouver leur ancêtre commun le plus récent, puis de comparer chaque branche à cet ancêtre. Git applique ensuite les deux ensembles de modifications. Il n'y a conflit que lorsque les deux branches ont modifié les mêmes lignes du même fichier.

main.py
Sortie
>_ Prêt à exécuter…
Un conflit n'est pas une panne
Git signale un conflit lorsqu'il ne peut pas décider seul. Deux personnes ont modifié les mêmes lignes ; aucune règle automatique ne dit laquelle a raison.
La résolution appartient donc au développeur. Il ouvre le fichier, choisit ou combine, retire les marqueurs, indexe le fichier et termine la fusion. Rien ne se répare tout seul, et rien n'est cassé.
<<<<<<< HEAD
    int delai = 30;
=======
    int delai = 60;
>>>>>>> correctif-log

Les marqueurs délimitent la version de la branche courante, puis celle de la branche fusionnée. Le fichier reste dans cet état tant que quelqu'un n'a pas tranché.

L'erreur classique de résolution
Supprimer les marqueurs sans lire, en gardant l'une des deux versions au hasard, produit un fichier syntaxiquement correct et fonctionnellement faux. Le conflit disparaît, le défaut reste, et il est désormais invisible.
Un conflit se résout en comprenant les deux intentions. Quand elles s'excluent, la discussion avec l'autre auteur coûte moins cher que le défaut.

Dépôt local et dépôt distant

Le dépôt distant n'a rien de particulier : c'est un dépôt comme les autres, désigné par un nom, généralement origin.

OpérationEffet
git clonecopie un dépôt distant en local, historique compris
git fetchrécupère les commits distants sans toucher au travail en cours
git pullfetch suivi d'une fusion dans la branche courante
git pushenvoie les commits locaux vers le distant
Ne jamais réécrire un historique déjà publié
Les commandes qui réécrivent l'historique, comme rebase ou commit --amend, changent les empreintes. Sur des commits restés locaux, l'opération est sans risque et souvent utile pour ranger son travail avant de le publier.
Sur des commits déjà poussés et récupérés par d'autres, elle crée deux versions divergentes de la même histoire. Les collègues se retrouvent avec des commits en double et des fusions incompréhensibles.

Le découpage et les messages

Un historique ne vaut que par sa lisibilité. Deux habitudes suffisent à la produire.

Un commit, une intention. Un commit qui corrige un défaut et reformate deux cents lignes empêche de lire la correction, et rend impossible son annulation isolée.

Un message qui dit pourquoi. Le diff montre déjà ce qui a changé. Ce qu'il ne montre pas, et ce qu'on cherchera dans six mois, c'est la raison du changement.

Deux messages pour le même commit
correction bug n'apprend rien. On sait déjà qu'il s'agit d'une correction, et le diff dit laquelle.
Corrige la perte du dernier enregistrement lors d'une écriture concurrente permet de retrouver ce commit, de comprendre le problème, et de savoir s'il concerne le défaut qu'on est en train d'étudier.

Un fichier .gitignore complète le dispositif en excluant ce qui n'a pas à être versionné : produits de compilation, dépendances téléchargées, fichiers de configuration locale, secrets. Un dépôt qui contient un mot de passe le contient pour toujours, y compris après suppression, puisque l'historique conserve tout.

Exercices type

Quelle différence entre git add et git commit ?

git add place une modification dans l'index, la zone qui prépare le prochain commit. Rien n'est encore enregistré dans l'historique.

git commit transforme le contenu de l'index en un commit, avec auteur, date et message.

L'intérêt de cette séparation est de composer un commit qui ne reprend qu'une partie du travail en cours. On peut avoir modifié cinq fichiers et n'en enregistrer que deux, pour séparer deux intentions différentes.

Pourquoi l'historique est-il un graphe et non une liste ?

Parce qu'un commit de fusion possède deux parents. La ligne d'histoire se sépare quand on crée une branche et se rejoint quand on la fusionne.

Cette structure est un graphe orienté sans cycle : les arêtes vont de l'enfant vers ses parents, et l'on ne peut pas remonter à soi-même.

C'est ce qui permet à Git de calculer une base commune entre deux branches, opération impossible sur une simple liste.

Deux développeurs modifient la même ligne. Que fait Git ?

Il signale un conflit et interrompt la fusion, en insérant dans le fichier des marqueurs qui délimitent les deux versions.

Il ne choisit pas, parce qu'aucune règle automatique ne peut décider laquelle des deux intentions doit l'emporter.

Le développeur ouvre le fichier, décide, retire les marqueurs, indexe le fichier résolu et achève la fusion. Si les deux modifications avaient porté sur des lignes différentes du même fichier, Git aurait fusionné sans rien demander.

Pourquoi ne pas faire un seul gros commit en fin de journée ?

Parce qu'il devient inutilisable pour tout ce à quoi sert un historique.

On ne peut pas annuler une partie du travail sans annuler le reste. On ne peut pas retrouver quand un défaut a été introduit, puisque le commit contient tout. On ne peut pas relire la modification, car le diff mélange plusieurs sujets.

Le découpage utile suit les intentions, pas le temps : un commit par correction, par fonctionnalité, par renommage.

Un mot de passe a été commité puis supprimé au commit suivant. Le dépôt est-il propre ?

Non. L'historique conserve tous les états : le commit qui contenait le mot de passe existe toujours, et quiconque possède le dépôt peut le lire.

La suppression au commit suivant ne fait que retirer le fichier de l'état courant.

Deux actions sont nécessaires, dans cet ordre. Considérer le secret comme compromis et le changer, car il a pu être cloné. Puis, éventuellement, réécrire l'historique pour l'en retirer, opération lourde qui suppose que tout le monde reprenne son dépôt.

La prévention coûte moins cher : un .gitignore sur les fichiers de configuration, et les secrets hors du dépôt.

À quoi sert une branche si l'on travaille seul ?

À isoler une modification incertaine. Si l'essai ne donne rien, on supprime la branche et il ne reste aucune trace dans la branche principale.

Elle permet aussi de traiter une urgence sans abandonner le travail en cours : on repart de la branche principale, on corrige, on livre, puis on revient.

Enfin, elle sépare les intentions dans l'historique. Deux fonctionnalités développées en parallèle sur la même branche produisent des commits entrelacés qu'on ne peut plus ni relire ni annuler séparément.

La méthode

  1. Regarde git status avant toute opération. La plupart des erreurs viennent d'un état mal identifié.
  2. Compose tes commits par intention, en t'aidant de l'index plutôt qu'en enregistrant tout.
  3. Écris le pourquoi dans le message, le diff dit déjà le quoi.
  4. Ouvre une branche par correctif ou par fonctionnalité, et supprime-la après fusion.
  5. Lis les deux versions avant de résoudre un conflit, et discute quand elles s'excluent.
  6. Ne réécris jamais un historique publié.
  7. Tiens un .gitignore dès le premier commit, et garde les secrets hors du dépôt.

En résumé

  • Un gestionnaire de versions répond à trois besoins : traçabilité, retour en arrière, travail parallèle.
  • Git est distribué : chaque poste détient l'historique complet.
  • Trois zones : répertoire de travail, index, dépôt local. L'index permet de choisir ce qu'on enregistre.
  • Un commit référence ses parents, ce qui fait de l'historique un graphe orienté sans cycle.
  • Une branche est un pointeur vers un commit. Sa création ne copie rien.
  • Une fusion compare les deux branches à leur ancêtre commun le plus récent.
  • Un conflit survient quand les mêmes lignes ont changé des deux côtés. Il se résout à la main.
  • Réécrire un historique déjà publié crée deux versions divergentes de la même histoire.
  • L'historique conserve tout : un secret commité reste lisible après suppression.

Et ensuite ? Le code versionné doit s'exécuter quelque part. Le chapitre suivant décrit l'environnement d'exécution que suppose le reste du bloc : la plateforme .NET.

La gestion de versions avec Git | Plateforme ETS