lundi 30 janvier 2012

[Git] Rejouer l'historique pour le modifier

... ou comment ajouter du contenu sur un commit qui n'est pas le dernier de la branche.

Les opérations suivantes ne suffiront pas pour propager le nouveau contenu jusqu'à la référence de la branche : se déplacer sur un commit antérieur (git checkout), ajouter du contenu (git add) et modifier le commit (git commit --amend). Cela aura pour seul effet de créer une nouvelle branche sans référence, car Git ne modifie pas des objets existants.
Il faut donc rejouer tous les commits depuis le commit à modifier jusqu'au dernier commit de la branche, afin de reconstruire celle-ci :
git rebase -i <commit> pour un rebase interactif depuis le commit indiqué.
Git guide ensuite l'utilisateur.

Dans l'éditeur ouvert par Git, remplacer, pour le commit à modifier, la commande "pick" par la commande "edit" sur la ligne correspondante, puis quitter l'éditeur. Le déroulement du rebase est suspendu en arrivant sur le commit à modifier : c'est le moment d'ajouter le contenu souhaité.
git add <fichier(s)> pour modifier l'index.
git commit --amend pour modifier le dépôt local.
git rebase --continue afin de poursuivre le rebase.
En lisant l'historique des commits de la branche pour le fichier modifié (git log --stat -- <fichier>) ou en ouvrant ce fichier, on voit que les modifications du commit intermédiaire ont été conservées, ce qui ne serait pas le cas sans un rebase.

jeudi 26 janvier 2012

[Git] Bonne pratique : Où (ne pas) effectuer un commit ?

Comme un checkout permet de se déplacer sur n'importe quel noeud du graphe, il est aussi possible d'effectuer un commit au milieu d'une branche et donc d'ajouter un noeud dont le parent est au milieu de cette branche. Or ce n'est pas une bonne idée, car ce noeud ne sera pointé par aucune référence connue, et il faudra se souvenir de la clé sha1 pour être capable de se repositionner sur ce noeud. La bonne pratique qui découle de cela est :

Toujours se trouver sur une référence de type branche avant de faire un commit.

[Git] Préparer un commit de manière intéractive

L'existence de l'index (espace transitoire entre le working directory et le dépôt local) permet d'ajouter du contenu pour le prochain commit en plusieurs fois, grâce à la commande git add. Mais l'utilisation de git add ne s'arrête pas là :
  • L'ajout interactif, avec git add -i (ou --interactive) : Git demande alors, fichier par fichier, la commande à appliquer.
  • L'option patch ou ajout par hunk, avec git add -p (ou --patch) : Un hunk correspondant à un plus ou à un moins lors d'un diff, Git demande donc, pour chaque modification / hunk, si oui ou non il faut l'ajouter dans l'index.
Cela permet de préparer un commit en tout sérénité, notamment lorsque l'on veut que des modifications réalisées sur un même fichier ne soient pas enregistrées dans le même commit. Chaque commit - unitaire - pourra ainsi correspondre vraiment à une seule tâche, pour un historique clair et compréhensible, même après des mois.

samedi 14 janvier 2012

Comprendre Git

Dans le présent billet, je vais présenter les quelques concepts vraiment importants pour comprendre Git, le très populaire SCM (Source Code Management) distribué.
Une chose dont il faut tout de suite prendre conscience lorsqu'on s'attaque à Git est qu'il ne faut surtout pas chercher à tisser un lien entre Git et d'anciens SCM comme Subversion. A vouloir utiliser Git comme on utilisait SVN, non seulement on passerait à côté de toute la puissance de l'outil, mais plus grave encore on s'exposerait à de douloureux retours de fortune.

Pour commencer, les concepts propres à Git sont simples à comprendre. Et c'est la combinaison de ces concepts entre eux qui confèrent à Git sa puissance d'utilisation, ainsi que l'efficacité de son implémentation. Voici ce que nous présenterons de Git, et qui se résume comme suit.
Git est un :
  • gestionnaire de contenu,
  • stocké sous la forme d'un graphe acyclique d'objets,
  • accessible par des références.
Git est structuré en 2 partie :
  • le frontend, qui regroupe les commandes dites porcelain (add, commit, checkout, etc).
  • le backend, développé par Linus Torvalds, qui réunit les commandes dites plumbing (hash-object, cat-file).
On retrouve cette distinction dans la littérature informatique et dans les formations : souvent, les formations pour débutants présentent le frontend, et le backend ne sera traité que par les formations avancées. Les dissocier de cette manière est une erreur, car en maîtrisant le backend, on maîtrisera bien mieux le frondend.


Concept #1 : Git est un gestionnaire de contenu.

Ce concept provient du backend, mais il influence fortement le frontend.
Le backend de Git est un backend de type "Snapshot" (ou Contenu), au contraire de SVN dont le backend est de type "Delta" (ou Fichier). Dans un backend "Delta", on ajoute d'abord un fichier, puis on enregistre pour ce fichier seulement la différence entre 2 versions. Au contraire, dans un backend "Snapshot", on ajoute un contenu, puis à chaque modification de ce contenu, on enregistrera le nouveau contenu dans sa globalité : chaque contenu est enregistré dans un objet rouge qui correspond à un blog, et il existe un objet bleu pour indiquer l'emplacement du fichier du même contenu.

Pour des scénarii usuels dépassant le cas d'une simple modification, comment cela fonctionne-t-il concrètement ?
  • Lors d'un retour-arrière ou revert sur un fichier ? Le backend "Delta" (SVN, CVS, etc) trace les changements successifs : les lignes A ont été ajoutées d'abord, les lignes B ont été ajoutées ensuite, enfin on supprime les lignes B et on ajoute les lignes A.  Le backend "Snapshot" (Git) a créé deux contenus, il met alors à jour l'objet bleu pour indiquer que ce fichier contient désormais le contenu précédent.
  • Lors d'une duplication de fichier vers un autre ? Le backend "Delta" trace deux fichiers ayant chacun leur contenu, même si ces contenus sont identiques. Le backend "Snapshot" voit un seul contenu (un objet rouge) et deux fichiers (deux objets bleus).
  • Lors des renommages successifs d'un fichier ? Le backend "Delta" historise différents fichiers qui seront modifiés au fil du temps. Le backend "Snapshot" enregistre les différentes modifications en autant de contenus (objets rouges), et il dispose d'objets bleus pour indiquer le nom du fichier pour chaque contenu.
Dans un projet où l'on manipule un grand nombre de fichiers, on entrevoit les difficultés que va rapidement rencontrer un backend "Delta", tandis qu'un backend "Snapshot" ne s'éloignera jamais de son comportement normal.

Pour gérer du contenu, Git s'appuie sur une base de données clé-valeur, et sur 2 commandes :
  • hash-object, qui reçoit un fichier, crée un objet rouge (blob), et renvoie une clé de hashage.
  • cat-file, qui reçoit une clé, et renvoie le contenu associé.
Git utilise l'algorithme SHA1 comme fonction de hashage cryptographique pour générer des clés, SHA1 étant plus robuste que MD5. Cela permet à Git d'obtenir une clé unique en fonction d'un contenu donné. En effet, un ordinateur qui produirait un péta-objet par seconde pendant une péta-année produirait en tout 3,1536 x 10^37 objets. Comme SHA1 est en mesure de produire 2^160 ou 10^48 clés différentes, il est simplement impossible d'obtenir la même clé pour deux contenus différents, dans l'univers, dans l'histoire de l'Humanité...

En bref, Git dispose de 3 types d'objet dans sa base de données :
  • les objets blog en rouge, qui servent à enregistrer du contenu et à renvoyer un SHA1 ;
  • les objets tree en bleu, qui font la relation entre un contenu et un fichier ;
  • les objets de commit en vert, pour dire qui produit le commit, à quelle date et pourquoi.

Comme les objets tree peuvent atteindre de grandes tailles en comparaison des objets de commit, ils sont séparés dans l'implémentation de Git, afin d'être plus efficace.

En résumé du concept #1, Git est un gestionnaire orienté contenu, et non fichier, qui utilise une base de données clé (sha1) / valeur, comportant 3 types d'objet : commit, tree, blob.


Concept #2 : La relation entre commits est un graphe acyclique (ou DAG).

Si le concept #1 était purement issu du backend, le concept #2 est à cheval entre le frontend et le backend.

Un graphe acyclique est un graphe sans cycle (sans boucle). Dans ce graphe, un noeud peut avoir 0, 1, 2 ou N parents. Pour information, Git est seul à supporter N parents pour un même noeud. Par ailleurs - point très important dans l'implémentation de Git -, l'enfant pointe vers le parent, Git pointe donc vers le passé. Avec SVN, c'est le contraire, SVN pointe vers le futur, ce qui n'a pas d'intérêt pour un SCM et qui est à l'origine de gros problèmes.

Avec Git, ajouter du contenu consiste à se placer sur un noeud X, vouloir le contenu d'un noeud Y et appliquer une opération entre X et Y de son choix : commit, merge, rebase, squash, cherrypick, revert. Chacune de ces opérations permet de transformer le graphe d'une manière différente : on choisit donc l'opération à appliquer en fonction du graphe que l'on veut obtenir après transformation.

Commit : continuer le graphe
Un commit ajoute un nouveau noeud à la suite du dernier noeud de la branche courante.

Merge : modéliser une fusion
Un merge fait la fusion entre deux branches du graphe en un point pour n'en obtenir qu'une.
NB : Avec SVN, on manipule un arbre dégénéré (avec une seule branche), et un merge ne le modifie pas, il ne fait qu'ajouter du contenu à une branche à partir d'une autre.

Rebase : simplifier un graphe
En partant de 2 branches distinctes, un rebase place une branche en tant qu'enfant d'une autre pour n'en obtenir qu'une.

Squash : nettoyer le graphe
Un squash agrège deux ou plusieurs noeuds pour ne garder que le dernier enfant de ces noeuds.

Cherrypick : faire une copie d'un contenu sans son historique
Dans une branche d'expérimentation par exemple, un seul noeud nous intéresse, pas les autres. On veut donc récupérer cet unique noeud en l'insérant dans la branche adéquate.

Revert : faire un retour-arrière sur un noeud
Un revert crée un nouveau noeud qui annule le contenu du noeud visé.

Finalement, la vision chronologique n'a pas de sens, la seule vision correcte est typologique, c'est-à-dire l'agencement des noeuds au sein du graphe, et non la date de création des noeuds. Effectivement, les opérations de transformation du graphe sont susceptibles de bouleverser à volonté l'ordre chronologique.

En résumé du concept #2, les commits sont reliés dans un graphe, manipulable à loisir, où les objets sont immutables : Git ne modifie jamais les objets qu'il a créés, il ne fait qu'en ajouter de nouveaux.


Concept #3 : Le contenu est accessible sous forme de références.

Git manipule deux types de référence :
  • les références déplacées automatiquement par Git,
  • les références déplacées par l'utilisateur.
Avec Git, la notion de référence est indispensable afin de connaître le dernier noeud pour chaque branche du graphe. Comme les noeuds de Git pointent de l'enfant vers le parent, sans référence, il faudrait se souvenir des SHA1. SVN pourrait se passer de référence, puisque ses noeuds pointent du parent vers l'enfant. Git considère d'ailleurs qu'un head inaccessible par référence est à effacer. Au lancement d'une commande Git, le garbage collector de Git passe pour effacer les références inaccessibles, et ce tous les trois mois par défaut.

Références automatiquement déplacées par Git :
Une branche est une référence (déplacée par Git) sur un noeud du graphe. Concrètement, Git crée une branche en créant un fichier de 40 octets qui contiendra sa référence (cheap branching).
Par défaut, à la création d'un dépôt (init), la branche principale est la branche master (équivalent du trunk de SVN), et le dépôt distant partagé s'appelle origin (clone).
Il existe une référence head qui désigne en permanence le noeud actif du graphe.


Références déplacées par l'utilisateur :
Git dispose d'un 4ème type d'objet dans sa base de données : les objets tag en orange, qui permettent de référencer un commit par un nom spécifique. Un tag est une référence définie par l'utilisateur, notamment pour les releases. Git supporte les références avec des namespaces (exemple : toto/titi/tutu). En termes d'implémentation, les namespaces sont en fait des répertoires, et les références des fichiers.

En résumé du concept #3, une référence est un pointeur (post-it), utilisable avec des espaces de nom, utile pour l'utilisateur (frontend).


Conclusion

La conception de Git peut être vue comme un modèle en couche, avec :
  • Un contenu, implémenté par des objets blob,
  • Un système de fichier constitué d'objets tree par-dessus le contenu,
  • Un historique qui encapsule l'ensemble grâce à des objets commit,
  • Facilement mémorisable et accessible par le biais de références.
Si vous avez compris les concepts que nous venons de voir, alors vous avez compris comment fonctionne Git. C'est plutôt une bonne nouvelle, non ?

Liens