Git - L'historique local

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 fait partie d'une série expliquant quelques fonctionnalités avancées que j'utilise régulièrement.

  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

Chercher des informations dans l'historique

Afficher l'historique complet devient souvent difficile à lire lorsque le nombre de commits augmente et que le projet grossit. Heureusement, la commande git log dispose d'un certain nombre de paramètres permettant de filtrer la liste des commits, ou modifier les informations affichées, afin de trouver plus facilement les commits recherchés.

Filtres simples

Parfois, un filtrage simple permet de trouver rapidement le bon commit. Parmi les filtres simples, il existe :

  • --grep <regex> : Liste les commits dont la description correspond à l'expression régulière donnée.
  • -G <regex> : Liste les commits dont le diff correspond à l'expression régulière donnée.
  • -S <string> : Liste les commits dont le diff change le nombre d'occurences de la chaîne donnée.
  • --author=<pattern> : Liste les commits dont l'auteur correspond à la valeur, totale ou partielle, donnée.
  • --merges : Affiche uniquement les commits de merge.
  • --no-merges : N'affiche pas les commits de merge.
  • --until=<timespec> : Liste les commits dont la date est avant <timespec>(même format que pour les commits).
  • --since=<timespec> : Liste les commits dont la date est après <timespec>(même format que pour les commits).

Note : Il est possible de cumuler la plupart de ces filtres. Cependant, je n'ai pas réussi à comprendre le résultat retourné lors de l'utilisation de plusieurs -G, plusieurs -S, ou de -G et -S en même temps.

Lister les commits de plusieurs références

Il est possible de lister les commits de plusieurs références simplement en donnant leurs noms en paramètre, par exemple git log HEAD branch-name other-branch master some-tag remote-name/branch-name.

Cependant, il peut être nécessaire d'afficher l'historique d'un nombre potentiellement important de références, sans pouvoir les préciser une par une simplement. Pour ces cas, il existe ces paramètres :

  • --all : Liste les commits de toutes les références connues du dépôt.
  • --branches[=<pattern>] : Liste les commits des branches correspondant à la valeur donnée.
  • --tags[=<pattern>] : Liste les commits des tags correspondant à la valeur donnée.
  • --remotes[=<pattern>] : Liste les commits des branches des dépôts distants correspondant à la valeur donnée.
  • --glob=<pattern> : Liste les commits des références dont le chemin total correspond à la valeur donnée.

Pour les paramètres --branches, --tags et --remotes, si aucune valeur n'est donnée, toutes les références du type demandé sont retournées.

Inverser des critères

Il est possible d'inverser certains critères, pour exclure des commits de la liste, au moyen du paramètre --not. Ajouter un second paramètre --not rétablit le fonctionnement des critères suivants. Seuls les critères désignant directement des commits ou des références sont affectés par ce paramètre. Ainsi, --branches et --glob le sont, mais pas --author ni --until.

Par exemple, pour afficher l'historique de la branche master, en excluant l'historique de la branche mergée par le commit 5877b7b, on utilisera :

$ git log master --not 5877b7b^2

Comme autre exemple, voici un alias que j'utilise pour lister rapidement les branches locales contenant des commits qui ne sont présents sur aucun dépôt distant.

$ git config --global alias.unpushed "log --pretty=format:'%d' --branches --not --remotes"

Modifier le format d'affichage

Il est possible de personnaliser la manière dont les commits sont affichés par la commande git log. Un format prédéfini que je trouve plus lisible peut être obtenu avec la commande git log --oneline --graph.

Cependant, ce format ne me convient pas non plus totalement, principalement parce que je trouve qu'il y manque des informations. J'ai donc défini un alias git llog correspondant à cette commande :

$ git log --pretty=format:'[%C(cyan)%G? %C(yellow)%h%C(reset)] [%C(green)%ad%C(reset)] %C(cyan)%an%C(reset): %C(bold)%s%C(reset) %C(green)(%cr)%C(reset)%C(auto)%d%C(reset)%+N' --date=short --date-order --graph

On y trouve, dans l'ordre :

  • %G : Validité de la signature GPG, en cyan.
  • %h : Hash court, en jaune.
  • %ad : Date (de la forme YYYY-MM-DD), en vert.
  • %an : Nom de l'auteur, en cyan.
  • %s : Description, en gras.
  • %cr : Date relative, en vert.
  • %d : Liste des références pointant actuellement ce commit, s'il y en a, utilisant les couleurs par défaut.
  • %+N : Les notes, s'il y en a, sur une nouvelle ligne.

Suivre les modifications d'un bloc de code

Il peut être compliqué de chercher les différents commits ayant mené à une partie du code. En effet, même si la commande git blame indique le dernier commit à avoir modifié chaque ligne, ce n'est pas forcément celui ayant appliqué la modification recherchée. Il faut alors utiliser à nouveau git blame en partant de son commit parent, jusqu'à trouver le bon.

Heureusement, git permet de simplifier ce travail, en proposant de suivre les modifications d'un bloc de code dans tout l'historique d'un fichier. Pour cela, il suffit de lui désigner la ligne de début, la ligne de fin, et le fichier à suivre. Il va alors rechercher tous les commits modifiant cette partie du fichier, en tenant compte des différences d'offset ajoutées par les différents commits, puis afficher la liste des modifications trouvées.

$ git log -L <start>,<end>:<path/fichier>

Par extension de cette fonctionnalité, git propose aussi de suivre automatiquement l'historique des modifications d'une fonction dans un fichier. Pour cela, il utilise l'expression régulière du driver de diff pour détecter les lignes de début de chaque fonction. De cette manière, il peut définir automatiquement les valeurs de début (début de la fonction recherchée) et fin (début de la fonction suivante) du bloc de code à suivre. Si plusieurs noms de fonction correspondent à l'expression régulière fournie, la première trouvée est utilisée.

$ git log -L :<funcname>:<path/fichier>

Travailler par numéros de lignes de début et fin de la fonction lui permet de continuer à suivre l'évolution de cette fonction, même lorsqu'elle est renommée.

Parcourir l'historique des actions locales

En plus de versionner le contenu, git conserve aussi un historique des actions effectuées en local qui modifient le HEAD (commits, rebase, checkout, pull, ...), Cela peut permettre de retrouver, par exemple, la branche sur laquelle on était avant le dernier checkout, ou à quel commit se trouvait la branche qu'on vient d'écraser par erreur (par un git reset --hard ou git commit --amend, par exemple).

Pour cela, il faut utiliser la commande git reflog, qui affiche l'historique des références passées en paramètre, ou de HEAD si aucun paramètre n'est passé. Il est aussi possible de passer le paramètre --all pour afficher l'historique de toutes les références en même temps. Tous les paramètres acceptés par la commande log sont acceptés ici, il est donc par exemple possible d'afficher l'historique des deux dernières semaines avec la commande git reflog --all --until=2weeks.

Par défaut, la référence relative de chaque commit est affichée (par exemple branch-name@{1}). Cependant, il est aussi possible de faire afficher le format date en ajoutant le paramètre --date=<format>, avec l'un des formats acceptés par la commande log (local, iso...). Dans ce cas, c'est la date de l'action ayant mené au changement d'état du HEAD qui est affichée, pas celle du commit correspondant.

Note : Lors de l'utilisation de répertoires de travail secondaires, chaque répertoire de travail a son propre historique.

Sauvegarder des modifications pour les retrouver plus tard

Lors d'un développement, il peut arriver de devoir changer de branche temporairement (pour faire un test sur une autre version, corriger un bug en urgence, etc.). Si le changement de branche n'est pas amené à durer, il peut être contraignant de passer par la création d'un worktree pour un petit changement ponctuel. Git permet alors de retirer les modifications du répertoire de travail et/ou de l'index, tout en les conservant, afin de pouvoir les réappliquer plus tard, avec la commande git stash. Cela permet d'éviter de polluer le test ou la correction à faire avec des modifications en cours de développement.

Attention : L'historique des modifications mises de coté n'est conservé que dans le reflog. Si le garbage collector de git ne supprimera jamais jamais ces entrées du reflog, il faut se méfier des commandes de nettoyage trop violentes, comme git reflog expire --all --expire=now qui vide totalement le reflog. En cas de suppression des entrées du reflog, seule la référence la plus récente restera accessible, puisqu'elle persiste dans refs/stash.

Définir les éléments à mettre de coté

La commande git stash save permet de retirer des modifications en cours du répertoire de travail et/ou de l'index, pour les mettre de coté, afin de pouvoir les retrouver plus tard. Appelée sans paramètre, elle prend toutes les modifications en cours, et les enregistre avec une description indiquant le nom de la branche d'origine, ainsi que le hash et la description de son dernier commit. Ses paramètres permettant de sélectionner plus finement les modifications qui seront mises de coté :

  • --patch (abrégé -p) : Permet de décider, bloc par bloc, quelles modifications mettre de coté.
  • --keep-index (abrégé -k) : Ne met de coté que les modifications du répertoire de travail, laissant l'index intact.
  • --include-untracked (abrégé -u) : Inclus les fichiers non versionnés dans les éléments mis de coté.
  • --all (abrégé -a) : Inclus les fichiers non versionnés et ignorés dans les éléments mis de coté.

Enfin, le dernier paramètre est utilisé comme description pour les modifications mises de coté.

Note : Appeler la commande git stash sans sous-commande, ni paramètre, est équivalent à l'appel de la commande git stash save sans paramètre.

Afficher des informations sur les modifications sauvegardées

Quand on a mis plusieurs modifications de coté, il est parfois nécessaire de les lister, puis d'en afficher le contenu, pour retrouver celle qu'on veut appliquer, ou supprimer celles qui sont devenues inutiles.

On utilise alors la commande git stash list pour afficher la liste des stashs, et git stash show stash@{count} pour afficher la liste des fichiers affectés par l'une de ces modifications. Ajouter le paramètre --patch (abrégé -p) permet d'afficher en plus le détail des modifications qui ont été enregistrées.

Appliquer les modifications sauvegardées

Les modifications sauvegardées peuvent être réappliquées au moyen des commandes git stash pop, qui détruit le stash après application en cas de succès et git stash apply, qui conserve toujours le stash appliqué. Selon l'état courant du dépôt et du répertoire de travail, appliquer des modifications mises de coté peut provoquer l'apparition de conflits, qu'il faudra résoudre. Elles acceptent aussi le paramètre --index, qui tente de restaurer exactement l'état du répertoire de travail et de l'index au moment de la sauvegarde.

Il existe aussi la commande git stash branch <branch-name>, qui crée une nouvelle branche au commit en cours lors de la sauvegarde des modifications, puis y applique les modifications. Cette commande restaure toujours l'état exact du répertoire de travail et de l'index, ainsi que les éventuels fichiers non versionnés et ignorés, s'ils ont été sauvegardés.

Supprimer les modificatios sauvegardées

La suppression des modifications devenues inutiles, par exemple après la résolution du conflit généré par l'application de ces modifications, se fait avec la commande git stash drop stash@{count}.

Attention : Sans paramètre, cette commande supprime automatiquement le stash le plus récent.

Envoi des modifications sauvegardées sur une autre machine

Étant gérées dans le reflog, les références vers les différents stash sauvegardés sont uniquement locales et ne sont pas poussées sur les dépôts distants par défaut. Même si ça peut paraître étrange, il est quand même possible de pousser un stash dans un dépôt distant, puisque les stash ne sont finalement que des commits un peu particuliers. Cette manipulation peut être utile lors de la migration d'une machine vers une autre, pour ne pas perdre les stashs de l'ancienne machine.

Ainsi, pousser un stash peut se faire avec la commande :

$ git push <remote-repo> stash@{count}:refs/heads/<some-branch-name>

Dans l'autre dépôt, on applique alors les modifications avec la commande :

$ git stash apply refs/heads/<some-branch-name>

Automatisation avec rebase

Git refuse de lancer une opération de rebase si des modifications sont présentes dans le répertoire de travail ou l'index. Heureusement, la commande git rebase --autostash permet d'exécuter automatiquement git stash avant le rebase, et git stash pop après. Il est possible de définir le paramètre rebase.autoStash dans la configuration pour activer ce comportement sans devoir mettre le paramètre manuellement lors de chaque appel. Il n'est toutefois pas actif par défaut, puisque l'application des modifications mises de coté peut provoquer des conflits difficiles à résoudre après l'exécution du rebase.

Réduire l'espace disque utilisé

La plupart des commandes de git qui modifient l'état du dépôt ne font que créer de nouveaux blobs, trees et commits, puis modifient certaines références (branches, etc.), mais ne suppriment pas de données. En effet, les commits "supprimés", et les données liées, ne sont détruits que périodiquement, par l'exécution du garbage collector de git, ce qui suffit pour la plupart des dépôts. La durée d'expiration des objets non accessibles est modifiable au moyen du paramètre gc.pruneExpire (par défaut deux semaines), qui accepte des valeurs dans le format timespec expliqué précédemment.

Cependant, dans certains cas, il peut être nécessaire de déclencher ce nettoyage manuellement, par exemple après avoir supprimé un dépôt distant contenant beaucoup de branches ou retiré de gros fichiers ajoutés par erreur dans l'index ou dans un commit précédent. La commande git prune supprime, par défaut, tous les éléments inaccessibles non contenus dans les packfiles du dépôt. Ajouter le paramètre --expire=<timespec> permet de limiter la quantité d'éléments supprimés à ceux plus anciens que la durée spécifiée.

Si les données qu'on s'attendait à voir supprimées du dépôt ne le sont pas, il est probable qu'elles soient encore référencées dans des entrées du reflog. Pour supprimer les entrées concernées, il faut utiliser la commande :

$ git reflog expire --all --expire-unreachable=<timespec>