Git - Généralités et notations

dim. 08 juil. 2018 by Marmotte

Git est un système de gestion de versions décentralisé. Le côté décentralisé implique que chaque client télécharge une copie complète du dépôt, la plupart des opérations étant donc réalisées directement sur la machine de l'utilisateur.

Cet article est le premier d'une série expliquant quelques fonctionnalités avancées que j'utilise régulièrement. Ces articles n'expliquent pas l'utilisation basique de git, parce que je n'ai pas besoin de notes à ce propos, et que de nombreuses ressources existent déjà à ce sujet.

  1. Généralités et notations
  2. Quelques configurations
  3. Notes diverses
  4. Dépôts et répertoires de travail
  5. L'historique local
  6. Retravailler l'historique des commits
  7. Trouver le commit introduisant une régression
  8. Récupérer des commits perdus

Note : Malgré ma préférence pour les mots français, j'utiliserai beaucoup d'anglicismes ici, principalement parce que je ne rencontre quasiment jamais les mots français correspondants dans mon utilisation quotidienne de git.

Les types d'objets

Git conserve les informations sous la forme de trois types d'objets :

  • Blob : Contenu brut d'un fichier.
  • Tree : Contenu d'un un répertoire. Les fichiers y sont représentés par le hash de l'objet blob correspondant, et les répertoires par le hash de l'objet tree correspondant.
  • Commit : Contient le hash de l'objet tree correspondant à la racine du dépôt pour l'état à conserver, les hashs des commits parents, l'auteur, la date du commit, et quelques autres informations.

Les branches, tags, stashs, et d'autres éléments, ne sont que des références vers des objets commit. Il existe aussi des références symboliques, comme HEAD, qui sont simplement des références vers des objets commit ou vers d'autres références (branches, tags, etc.).

On peut alors voir les données stockées par git comme un graph, où les éléments que nous manipulons directement (branches, tags, etc.) pointent vers des commits, qui pointent eux-mêmes vers des commits, trees et blobs, etc.

Enfin, pour optimiser l'espace disque utilisé, git génère de temps en temps des packfiles, qui sont des fichiers regroupant un ensemble d'objets. Alors qu'un objet blob correspondra toujours au contenu complet d'un fichier versionné, plusieurs objets blob correspondant à diverses versions du même fichier stockés dans un même packfile pourront partager leur contenu. L'espace total utilisé sera alors la taille de la dernière version de chaque fichier version, plus les différences entre celle-ci et chacune des autres versions.

Les trois états

Git différencie trois états des fichiers du dépôt :

  • Le répertoire de travail correspond aux fichiers présents sur le disque dur, modifiés directement par l'utilisateur.
  • Index permet de préparer ce qui sera sauvegardé lors de l'appel de la commande git commit. Bien que son contenu est déjà connu de git, cet état n'est pas encore réellement sauvegardé dans le dépôt git.
  • HEAD correspond à l'état du commit courant. C'est un état sauvegardé dans le dépôt git.

Le flux de travail basique lorsqu'on utilise git est donc souvent :

  • Créer et/ou modifier les fichiers du répertoire de travail.
  • Ajouter les modifications voulues dans l'index (au moyen de la commande git add, par exemple).
  • Valider ces modifications au moyen de la commande git commit, afin de créer un commit, et enregistrer les données dans le dépôt. Le HEAD est alors modifié pour pointer vers ce nouveau commit.

Notations

Dans la suite de cette série d'articles, j'utilise de nombreuses notations, qui ne paraissent pas toujours évidentes. Cette section explique les principales notations permettant de définir des références, commits et chemins.

Références

Les références peuvent être de différents types, souvent reconnu selon un préfixe. Ainsi, les références nommées refs/heads/* sont des branches, alors que les références nommées refs/tags/* sont des tags, et celles nommées refs/remotes/* sont des références provenant d'un dépôt distant. Il est aussi possible d'utiliser des références personnalisées, comme le font GitHub (les pull requests sont préfixées par refs/pull/) et GitLab (les merge requests sont préfixées par refs/merge-requests/).

Pour simplifier l'écriture, il est autorisé de ne pas préciser le préfixe des références, lorsqu'aucune confusion n'est possible. Les branches sont ainsi souvent désignées uniquement par leur nom simple, sans le préfixe refs/heads/. L'utilisation du préfixe est cependant nécessaire lorsque plusieurs références portent le même nom, par exemple, une branche locale nommée something/branch-name et une branche du dépôt distant something nommée branch-name.

Association de réféfences

Des commandes comme git push ou git fetch doivent associer les références locales avec les références d'un dépôt distant. Pour cela, il existe plusieurs possibilités, dont :

$ # Définition explicite de l'association entre les références
$ git fetch repo-name refs/heads/remote-reference-name:refs/heads/local-reference-name
$ # Omettre le préfixe est autorisé, lorsqu'aucune confusion n'est possible
$ git push repo-name local-branch-name:remote-branch-name
$ # Ne pas préciser de référence à pousser permet de supprimer la référence distante
$ git push repo-name :remote-branch-name
$ # Une référence ne faisant que désigner un commit, il est donc valide de donner directement le hash d'un commit
$ git push repo-name e497a0e:remote-branch-name

Il est autorisé de préciser plusieurs références dans la même commande, en les ajoutant toutes les unes à la suite des autres. L'exemple suivant pousse ainsi la branche master locale dans la branche master distante, la branche refactor locale dans la branche refactor distante, et enfin la branche some-branch dans la branche other-branch distante. Le caractère + ajouté pour la branche refactor permet de forcer le push de la branche. De cette manière, si le dernier commit distant n'est pas présent dans la branche locale, la référence distante sera modifiée quand même.

$ git push repo-name master:master +refactor:refactor some-branch:other-branch

Les caractères joker sont aussi acceptés, ce qui permet, par exemple, de pousser toutes les références d'un dépôt distant dans un autre. Dans l'exemple suivant, toutes les références liées au dépôt distant other-repo-name sont poussées dans des branches du dépôt distant repo-name, en écrasant les références éventuellement déjà existantes dans le dépôt destination.

$ git push repo-name +refs/remotes/other-repo-name/*:refs/heads/*

Commits

Si les manières les plus courantes de désigner un commit sont d'utiliser son hash ou le nom d'une référence vers ce commit, il est aussi possible d'employer des expressions avancées :

  • [branch-name]@{upstream} : Dernier commit de la référence distante liée à la branche spécifiée.

    Note : Cette notation peut être abrégée en [branch-name]@{u}.

    Note : Si branch-name est omis, la branche courante est utilisée.

    Astuce : Pour savoir facilement quelles modifications seront poussées, il est possible de définir un alias basé sur l'expression upstream décrite plus haut :

    $ git config --global alias.udiff "diff @{upstream}"
    
  • @{-count} : Commit désigné par la référence HEAD avant le count-ième changement de commit courant.

    Note : Le commit courant peut être changé par des commandes comme git checkout ou git rebase.

    Note : Ce critère est basé sur le reflog.

    Astuce : Cette notation permet de basculer simplement et rapidement entre deux branches, ou de fusionner rapidement la branche précédente dans une autre.

    $ # Retourner sur la branche précédente
    $ git checkout @{-1}
    $ # Raccourci pour effectuer la même opération
    $ git checkout -
    $ # Fusionner une branche quelconque dans la branche master
    $ git checkout master
    $ git merge @{-1}
    
  • [reference-name]@{timespec} : Dernier commit avant le moment désigné par le paramètre timespec.

    Note : Si reference-name est omis, la branche courante est utilisée.

    Note : Ce critère est basé sur le reflog.

    Note : Le format de l'expression timespec peut être :

    • count : Commit désigné par la référence avant la count-ième opération (commit, merge, rebase, etc.).
    • YYYY-MM-DD : Dernier commit désigné par la référence avant la date indiquée.
    • YYYY-MM-DD HH:MM:SS : Comme l'expression précédente, en spécifiant une heure en plus de la date.
    • Xunit : Un nombre et une unité de temps, par exemple 2700seconds, 3days ou encore 1month 10days. Les unités utilisables sont second, minute, hour, day, week, month, year et peuvent être données indifféremment au singulier ou au pluriel.
    • yesterday : Alias de 1day.
    • now : Alias de 0, équivalent à utiliser le nom de la référence seul. Cette valeur permet de forcer certaines commandes, comme git reflog, à afficher un format timestamp.

    Attention : Utiliser une référence symbolique comme reference-name donnera le commit correspondant à la date donnée. Par exemple, si HEAD désignait la branche other-branch une semaine plus tôt, utiliser la notationHEAD@{1week} donnera le commit auquel se trouvait la branche other-branch à ce moment, même si la branche courante est branch-name.

Il est aussi possible de désigner des commits par rapport à l'historique d'un commit précis :

  • commit~[count] : count-ième commit avant commit. Si count est omis, le premier parent est retourné.
  • commit^[count] : count-ième parent de commit. Si count est omis, le premier parent est retourné. Cette notation est utile principalement pour les commits ayant plusieurs parents, comme les commits de merge.

Note : commit peut ici être n'importe quelle expression désignant un commit. Ces expressions sont donc parfaitement valides : add194d^, branch-name~7, HEAD@{2}~6, branch-name@{yesterday}^4~15^2.

Ce dernier exemple correspond à un cas pas ou peu utilisé en pratique, pour montrer qu'il n'est parfois pas possible de faire plus court. En effet, celui-ci désigne le second parent du quinzième commit avant le quatrième parent du dernier commit de la veille sur la branche branch-name. Cela suppose donc que le dernier commit de la veille était un octopus merge d'au moins quatre branches, et que le quinzième commit avant celui-ci était lui aussi un merge.

Note : Utiliser les notations HEAD~~~~ ou HEAD^^^^ est aussi valide, mais on préfèrera utiliser HEAD~4, qui est un équivalent plus lisible.

Quelques exemples pratiques

Afficher les différences introduites par l'avant dernier commit effectué sur master. Dans le cas où ce commit a été fait avec le paramètre --amend, seules les différences apportées par celui-ci seront affichées, ce n'est donc pas équivalent à la commande git show <commit>.

$ git diff master@{2}..master@{1}

Récupérer l'état d'avant un reset --hard erroné. Seul l'état du HEAD sera cependant récupéré.

$ git reset --hard HEAD@{1}

Comparer le résultat obtenu après un rebase avec ce que la branche contenait avant.

$ git diff @{-1}

Chemins

Beaucoup de commandes git, dont git add, git diff, git log, git show, etc. acceptent des chemins de fichiers en paramètre. Bien qu'il soit possible de nommer les fichiers directement, ou d'utiliser des expressions basiques, comme *.md, les expressions avancées deviennent intéressantes lorsque l'on veut, par exemple, exclure certains types de fichiers.

Les expressions avancées se basent sur des opérateurs :

  • glob : Passe au format glob, dans lequel un caractère * seul correspond à n'importe quel caractère, sauf le séparateur de répertoire. Cela permet de rechercher dans un répertoire précis, plutôt que dans toute l'arborescence de ce répertoire. Il reste possible de rechercher dans une arborescence complète, en utilisant **.
  • literal : Les caractères * et ? ne sont plus considérés comme caractères joker.
  • icase : Rend le motif insensible à la casse.
  • top (abrégé /) : Permet de rechercher depuis la racine du dépôt, plutôt que le répertoire courant.
  • exclude (abrégé !) : Permet d'exclure des fichiers.

Les expressions avancées sont activées en préfixant le motif de chemin par le caractère :. Pour utiliser le format long, il faut placer les opérateurs à utiliser entre parenthèses, séparés par une virgule. Le format court s'utilise en plaçant directement les caractère de chaque opérateur juste après le caractère : de début. Il n'est pas possible de mélanger les deux formats, et dans les deux cas, l'ordre n'a pas d'importance.

Exemples :

$ # Affichage des modifications sur les fichiers du répertoire courant uniquement
$ git show -- ':(glob)*'
$ # Affichage de l'historique de tous les fichiers, sauf les fichiers de traduction
$ git log -- . ':!*.po' ':!*.pot'
$ # Équivalent ignorant uniquement les fichiers de traduction placés directement dans un répertoire nommé i18n
$ git log -- . ':(glob,exclude)**/i18n/*.po' ':(glob,exclude)**/i18n/*.pot'
$ # Ajout des modifications de tous les fichiers `png`, quel que soit la casse utilisée pour l'extension
$ git add -- ':(icase)*.png'
$ # Recherche des occurences du terme "product" dans tous les fichiers du dépôt, sauf les fichiers xml
$ git grep product -- ':/' ':!/*.xml'
$ # L'expression suivante est ressemblante, mais n'ignore que les fichiers xml du répertoire courant
$ git grep product -- ':/' ':!*.xml'

Note : Il est toujours nécessaire que le motif complet comporte au moins un motif d'ajout de chemins. C'est pour cette raison que les exemples qui n'utilisent qu'une expression contenant l'opérateur exclude (ou son équivalent court !) débutent par un ., pour inclure tout le répertoire courant, dans lequel on retirera les motifs voulus.