Contrôle de version (Git)

Les systèmes de contrôle de version (VCS) sont des outils utilisés pour suivre les modifications apportées au code source (ou à d’autres collections de fichiers et de dossiers). Comme leur nom l’indique, ces outils permettent de conserver un historique des modifications ; ils facilitent en outre la collaboration. Les VCS suivent les modifications apportées à un dossier et à son contenu dans une série d’instantanés, où où chaque instantané encapsule l’état complet des fichiers/dossiers d’un répertoire de premier niveau. Les VCS conservent également des métadonnées telles que la personne qui a créé chaque instantané, les messages associés à chaque instantané, etc.

Pourquoi le contrôle de version est-il utile ? Même lorsque vous travaillez seul, il peut vous permettre de consulter d’anciennes snapshots d’un projet, de garder une trace des raisons pour lesquelles certaines modifications ont été effectuées, de travailler sur des branches parallèles du développement, et bien plus encore. Lorsque vous travaillez à plusieurs, c’est un outil inestimable pour voir ce que d’autres personnes ont modifié, ainsi que pour résoudre les conflits dans le cadre d’un développement simultané.

Les VCS modernes vous permettent également de répondre facilement (et souvent automatiquement) à des questions telles que :

Bien qu’il existe d’autres systèmes de contrôle de version, Git est la norme de facto en matière de contrôle de version. Cette bande dessinée de XKCD comic illustre la réputation de Git :

xkcd 1597

Parce que l’interface de Git est une grande abstraction, apprendre Git de haut en bas (en commençant par son interface / son interface en ligne de commande) peut conduire à beaucoup de confusion. Il est possible de mémoriser une poignée de commandes et de les considérer comme des incantations magiques, et de suivre l’approche de la bande dessinée ci-dessus chaque fois que quelque chose ne va pas.

Bien que l’interface de Git soit laide, sa conception et ses idées sous-jacentes sont belles. Alors qu’une interface laide doit être mémorisée, une belle conception peut être comprise. C’est pourquoi nous expliquons Git de manière ascendante, en commençant par son modèle et en couvrant ensuite l’interface en ligne de commande. Une fois le modèle compris, les commandes peuvent être mieux comprises en termes de manipulation du modèle sous-jacent.

Modèle de Git

Il existe de nombreuses approches ad hoc pour le contrôle des versions. Git a un modèle modèle bien pensé qui permet de bénéficier de toutes les fonctionnalités intéressantes du contrôle de version, comme la conservation de l’historique, la prise en charge des branches et la collaboration.

Snapshots

Git modélise l’historique d’une collection de fichiers et de dossiers au sein d’un répertoire de top niveau comme une série de snapshots. Dans la terminologie de Git, un fichier est appelé “blob”, et il est vu comme un tas d’octets. Un répertoire est appelé “tree”, et il associe des noms à des blobs ou à des trees (les répertoires peuvent donc contenir d’autres répertoires). Une snapshot est le tree de top niveau qui est suivi. Par exemple, nous pourrions avoir une arborescence comme suit :

<root> (tree)
|
+- foo (tree)
|  |
|  + bar.txt (blob, contenu = "hello world")
|
+- baz.txt (blob, contenu = "git est un outil formidable")

L’arbre de top niveau contient deux éléments, un tree “foo” (qui contient lui-même un élément, un blob “bar.txt”), et un blob “baz.txt”.

Modélisation de l’historique : liaisons des snapshots

Comment un système de contrôle des versions doit-il relier les snapshots ? Un modèle simple consisterait à avoir un historique linéaire. Un historique serait une liste de snapshots classés dans le temps. Pour de nombreuses raisons, Git n’utilise pas un modèle aussi simple.

Dans Git, un historique est un graphe acyclique dirigé (DAG) de snapshots. Cela peut sembler être un mot mathématique compliqué, mais ne vous laissez pas intimider. Tout ce que cela signifie, c’est que chaque instantané dans Git fait référence à un ensemble de “parents”, les snapshots qui l’ont précédé. Il s’agit d’un ensemble de parents plutôt que d’un seul parent (comme ce serait le cas dans un historique linéaire) car une snapshot peut descendre de plusieurs parents, par exemple, en raison de la combinaison (merge) de deux branches parallèles de développement.

Git appelle ces snapshots des “commit”. La visualisation de l’historique des commits peut ressembler à quelque chose comme ceci :

o <-- o <-- o <-- o
            ^
             \
              --- o <-- o

Dans l’image ASCII ci-dessus, les o correspondent à des commits individuels (instantanés). Les flèches pointent vers le parent de chaque commit (il s’agit d’une relation “vient auparavant”, et non “vient postérieurement”). Après le troisième commit, l’historique se divise en deux branches distinctes. Cela peut correspondre, par exemple, à deux fonctionnalités distinctes développées en parallèle, indépendamment l’une de l’autre. À l’avenir, ces branches peuvent être fusionnées pour créer un nouvel instantané qui intègre les deux fonctionnalités, produisant un nouvel historique qui ressemble à ceci, avec le nouveau commit de fusion en gras :


o <-- o <-- o <-- o <---- o
            ^            /
             \          v
              --- o <-- o

Dans Git, les commits sont immuables. Cela ne signifie pas que les erreurs ne peuvent pas être corrigées, cependant ; c’est juste que les “éditions” de l’historique des commits créent en fait des commits entièrement nouveaux, et les références (voir ci-dessous) sont mises à jour pour pointer vers les nouveaux.

Modèle de données, sous forme de pseudocode

Il peut être instructif de voir le modèle de données de Git écrit en pseudo-code :

// un fichier est un ensemble d'octets
type blob = array<byte>

// un répertoire contient des fichiers et des répertoires nommés
type tree = map<string, tree | blob>

// un commit a des parents, des métadonnées et l'arbre de top niveau  
type commit = struct {
    parents: array<commit>
    author: string
    message: string
    snapshot: tree
}

Il s’agit d’un modèle d’historique simple et clair.

Objets et adressage du contenu

Un “objet” est un blob, un arbre ou un commit :

type object = blob | tree | commit

Dans le stockage de données de Git, tous les objets sont adressés en fonction de leur contenu par leur hash SHA-1.

objects = map<string, object>

def store(object):
    id = sha1(object)
    objects[id] = object

def load(id):
    return objects[id]

Les blobs, les trees et les commits sont unifiés de cette manière : ce sont tous des objets. Lorsqu’ils font référence à d’autres objets, ils ne les contiennent pas réellement dans leur représentation sur disque, mais y font référence par leur hachage.

Par exemple, l’arbre de l’exemple de structure de répertoire ci-dessus (visualisé à l’aide de la fonction git cat-file -p 698281bc680d1995c5f4caaf3359721a5a58d48d), ressemble à :

100644 blob 4448adbf7ecd394f42ae135bbeed9676e894af85    baz.txt
040000 tree c68d233a33c5c06e0340e4c224f0afca87c8ce87    foo

Le tree lui-même contient des pointeurs vers son contenu, baz.txt (un blob) et foo (un tree). Si nous regardons le contenu adressé par le hash correspondant à baz.txt avec git cat-file -p 4448adbf7ecd394f42ae135bbeed9676e894af85, nous obtenons ce qui suit :

git is wonderful

Références

Désormais, toutes les snapshots peuvent être identifiées par leur hachage SHA-1. Cela n’est pas pratique, car les humains ne sont pas doués pour se souvenir de chaînes de 40 caractères hexadécimaux.

La solution de Git à ce problème est d’utiliser des noms lisibles par l’homme pour les hashs SHA-1, appelés “références”. Les références sont des pointeurs vers les commits. Contrairement aux objets, qui sont immutables, les références sont mutables (elles peuvent être mises à jour pour pointer vers un nouveau commit). Par exemple, la référence master pointe généralement vers le dernier commit de la branche principale de développement.

references = map<string, string>

def update_reference(name, id):
    references[name] = id

def read_reference(name):
    return references[name]

def load_reference(name_or_id):
    if name_or_id in references:
        return load(references[name_or_id])
    else:
        return load(name_or_id)

Grâce à cela, Git peut utiliser des noms lisibles par l’homme comme master pour se référer à une snapshot particulière dans l’historique, au lieu d’une longue chaîne hexadécimale.

Un détail est que nous voulons souvent avoir une notion de “l’endroit où nous sommes actuellement” dans l’historique, de sorte que lorsque nous prenons une nouvelle snapshot, nous savons à quoi elle est relative (comment nous définissons le champ parents du commit). Dans Git, cet “endroit où nous sommes actuellement” est une référence spéciale appelée “HEAD”.

Repositories

Enfin, nous pouvons définir ce qu’est (en gros) un dépôt Git : il s’agit des objets et des références.

Sur le disque, tout ce que Git stocke, ce sont des objets et des références : c’est tout ce qu’il y a dans le modèle de données de Git. Toutes les commandes Git se traduisent par une manipulation du DAG de commit par l’ajout d’objets et l’ajout/mise à jour de références.

Chaque fois que vous tapez une commande, pensez à la manipulation qu’elle effectue sur la structure de données graphique sous-jacente. Inversement, si vous essayez d’apporter un type particulier de changement au DAG des livraisons, par exemple “supprimer les changements non commit et faire pointer la ref ‘master’ sur le commit 5d83f9e”, il y a probablement une commande pour le faire (par exemple, dans ce cas, git checkout master; git reset --hard 5d83f9e).

Zone d’attente

Il s’agit d’un autre concept orthogonal au modèle de données, mais qui fait partie de l’interface de création de commits.

L’une des façons d’implémenter la snapshot telle que décrite ci-dessus est d’avoir une commande “create snapshot” qui crée une nouvelle snapshot basée sur l’état actuel du répertoire de travail. Certains outils de contrôle de version fonctionnent ainsi, mais pas Git. Nous voulons des snapshots propres, et il n’est pas toujours idéal de faire une snapshot à partir de l’état actuel. Par exemple, imaginez un scénario dans lequel vous avez implémenté deux fonctionnalités distinctes, et vous voulez créer deux commits distincts, où le premier introduit la première fonctionnalité, et le suivant introduit la seconde fonctionnalité. Ou imaginez un scénario dans lequel vous avez ajouté des instructions de débogage (print) partout dans votre code, ainsi qu’une correction de bugs ; vous voulez valider la correction de bugs tout en supprimant toutes les instructions de débogage.

Git s’adapte à ces scénarios en vous permettant de spécifier quelles modifications doivent être incluses dans la prochaine snapshot grâce à un mécanisme appelé “staging area” (zone d’attente).

Interface de ligne de commande Git

Pour éviter de dupliquer des informations, nous n’allons pas expliquer les commandes ci-dessous en détail. Consultez le très recommandé Pro Git pour plus d’informations, ou regardez la vidéo de présentation.

Bases

Branchement et merge

Remotes

Annuler

Advanced Git

Divers

Resources

Exercices

  1. Si vous n’avez pas d’expérience avec Git, essayez de lire les deux premiers chapitres de Pro Git ou suivez un tutoriel tel que Learn Git Branching. Pendant que vous travaillez, faites le lien entre les commandes Git et le modèle de données.
  2. Clonez le dépôt du site web de la classe.
    1. Explorer l’historique des versions en le visualisant sous la forme d’un graphique.
    2. Qui a été la dernière personne à modifier README.md? (Indice: utiliser git log avec un argument).
    3. Quel était le commentaire associé à la dernière modification de la ligne collections: de _config.yml? (Indice: utiliser git blame et git show).
  3. Une erreur fréquente lors de l’apprentissage de Git est de commit des fichiers volumineux qui ne devraient pas être gérés par Git ou d’ajouter des informations sensibles. Essayez d’ajouter un fichier à un repository, d’effectuer quelques commits, puis de supprimer ce fichier de l’historique (vous pouvez consulter cette rubrique).
  4. Cloner un dépôt depuis GitHub, et modifier un de ses fichiers existants. Que se passe-t-il lorsque vous faites git stash? Que voyez-vous lorsque vous exécutez git log --all --oneline? Exécutez git stash pop pour annuler ce que vous avez fait avec git stash. Dans quel scénario cela peut-il être utile ?
  5. Comme beaucoup d’outils en ligne de commande, Git fournit un fichier de configuration (ou fichier point) appelé ~/.gitconfig. Créez un alias dans ~/.gitconfig pour que lorsque vous lancez git graph, vous obteniez la sortie de git log --all --graph --decorate --oneline. Des informations sur les alias git peuvent être trouvées ici.
  6. Vous pouvez définir des motifs d’ignorance globaux dans ~/.gitignore_global après avoir exécuté git config --global core.excludesfile ~/.gitignore_global. Faites cela, et configurez votre fichier gitignore global pour ignorer les fichiers temporaires spécifiques à un système d’exploitation ou à un éditeur, comme .DS_Store.
  7. Faites un fork du répertoire du site web de la classe, trouvez une faute de frappe ou une autre amélioration que vous pouvez apporter, et soumettez une demande de modification (pull request) sur GitHub (vous voulez peut-être jeter un oeil ici). Veuillez ne soumettre que des PR utiles (ne nous spammez pas, s’il vous plaît !). Si vous ne trouvez pas d’amélioration à apporter, vous pouvez ignorer cet exercice.

Modifier cette page.

Sous licence CC BY-NC-SA.