jeudi 18 février 2021
Ce billet a été vu pour la première fois sur le blog de Synbioz le 18 February 2021 sous licence CC BY-NC-SA.

Un meilleur rendu de Git diff pour Ruby

Si vous utilisez Git en ligne de commande, vous connaissez bien la sortie diff qui se décompose en morceaux (hunks) comme ci-dessous :

@@ -231,7 +231,7 @@ class TimeCalc
       case
       when from.class == Date
         [coerce_date(from, to), to]
-      when to.class == Date
+      when to.instance_of?(Date)
         [from, coerce_date(to, from)]
       else
         [from, to.public_send("to_#{from.class.downcase}")]

La première ligne (commençant par @@) est connue sous le nom de « hunk header » et est là pour nous aider à comprendre où a eu lieu le changement. Elle nous donne les numéros de ligne pour le changement (les numéros entre les paires d’arobases), mais aussi une description textuelle du contexte dans lequel le changement s’est produit, dans cet exemple class TimeCalc. Git essaie de comprendre ce contexte, qu’il s’agisse d’une fonction, d’un module ou d’une définition de classe. Pour les langages de type C, il est assez bon à ce niveau. Mais pour l’exemple Ruby ci-dessus, il n’a pas réussi à nous montrer le contexte immédiat, qui est en fait une méthode appelée coerce_classes. C’est parce que Git n’est pas capable de reconnaître la syntaxe Ruby pour une définition de méthode, qui ici serait def coerce_classes.

Ainsi, ce que nous voulons vraiment voir, c’est :

@@ -270,7 +270,7 @@ def coerce_classes(from, to)
       case
       when from.class == Date
         [coerce_date(from, to), to]
-      when to.class == Date
+      when to.instance_of?(Date)
         [from, coerce_date(to, from)]
       else
         [from, to.public_send("to_#{from.class.downcase}")]

Et il n’y a pas qu’avec Ruby où Git a du mal à trouver le bon contexte de clôture. De nombreux autres langages de programmation et formats de fichiers sont également perdants en ce qui concerne le contexte de l’en-tête.

Heureusement, il est non seulement possible de configurer une expression rationnelle personnalisée spécifique à votre langage pour aider Git à mieux s’orienter, mais il existe même un ensemble de modèles prédéfinis pour de nombreux langages et formats dans Git. Tout ce que nous avons à faire est de dire à Git quels modèles utiliser pour nos extensions de fichiers.

Nous pouvons le faire en définissant un fichier .gitattributes à l’intérieur de notre dépôt. Dans ce fichier, on associera les extensions de fichiers Ruby au modèle de diff prédéfini pour Ruby :

*.gemspec diff=ruby
*.rake    diff=ruby
*.rb      diff=ruby

Certains projets open source définissent leur propre fichier .gitattributes. Il y en a un dans Rails. Il y en a même un dans les sources Git qui défini les modèles de diff pour Perl et Python.

Configurer un fichier .gitattributes global

Au lieu d’ajouter un fichier .gitattributes à chaque dépôt, nous pouvons en configurer global. Il suffit de créer ce fichier dans votre répertoire personnel, de le remplir avec tous les formats de fichiers qui vous intéressent et de le déclarer dans votre configuration globale de Git :

❯ git config --global core.attributesfile ~/.gitattributes

On pourrait se demander pourquoi, s’il existe des modèles de diff prédéfinis, doit-on les déclarer nous-même ? Ça semble être une étape superflue dont on se passerait bien !

Oui mais voilà, tout n’est pas si simple ! Si vous avez dans votre dépôt un fichier *.c, faut-il lui appliquer le modèle de diff du langage C ou de C++ ? Si c’est un fichier *.m, est-ce de l’Objective-C ou du Matlab ? Ah, vous voyez, vous aussi vous êtes perdu ! Eh bien c’est parce qu’on ne peut pas se fier à la seule extension d’un fichier pour définir le modèle à appliquer, que les développeurs de Git ont décidé de ne pas arbitrer à notre place. Et si vous voulez revivre cette intense discussion, vous la retrouverez sur ce fil.

Modèle de diff pour Rspec

Comme annoncé plus tôt, il est aussi possible de définir un modèle de diff personnalisé. Cela peut être utile pour Rspec par exemple, de manière à prendre en considération son propre DSL.

Cela se fait en deux étapes. Premièrement, nous allons ajouter une ligne spécifique à nos fichiers de tests Rspec dans le fichier .gitattributes :

*.rb diff=ruby
*._spec.rb diff=rspec

Ensuite, il nous faut définir ce nouveau modèle dans $GIT_DIR/config au sein de notre projet ou dans $HOME/.gitconifg :

[diff "rspec"]
  xfuncname = "^[ \t]*((RSpec|describe|context|it|before|after|around|feature|scenario)[ \t].*)$"

On utilise dans l’exemple ci-dessus l’instruction xfuncname introduite plus récemment que funcname et qui, contrairement à son ainée, supporte les expressions rationnelles étendues ; ce qui n’est pas pour nous déplaire tant la syntaxe résultante s’en trouve épurée ! Et si vous n’êtes pas à l’aise avec les regex, on a quelques articles sur le sujet ;)

Un contexte étendu

Ainsi configuré, une option méconnue de git diff dévoile alors tout son potentiel ; il s’agit de -W ou --function-context. C’est une option similaire à celle de git grep et qui élargit le contexte des hunks de manière à montrer toute la fonction environnante. Ce contexte que l’on qualifierait de « naturel » peut permettre de mieux comprendre les changements effectués.

Si on reprend notre exemple, ça donnerait ceci :

❯ git diff -W
@@ -269,10 +269,10 @@ def zone(tm)
     def coerce_classes(from, to)
       case
       when from.class == Date # not is_a?(Date), it will catch DateTime
         [coerce_date(from, to), to]
-      when to.class == Date
+      when to.instance_of?(Date)
         [from, coerce_date(to, from)]
       else
         [from, to.public_send("to_#{from.class.downcase}")].then(&method(:coerce_zones))
       end
     end

Les plus attentifs d’entre vous auront repéré un petit couac…

À l’épreuve des balles ?

Pas tout à fait… il peut arriver qu’un faux se glisse dans les résultats. C’est notamment le cas, si l’on veut prendre un exemple, de cet extrait :

❯ git diff
@@ -268,9 +268,9 @@ def zone(tm)

     def coerce_classes(from, to)
       case
-      when from.class == Date # not is_a?(Date), it will catch DateTime
+      when from.instance_of?(Date)
         [coerce_date(from, to), to]
-      when to.class == Date
+      when to.instance_of?(Date)
         [from, coerce_date(to, from)]
       else
         [from, to.public_send("to_#{from.class.downcase}")]

On se serait attendu à voir affiché def coerce_classes(from, to) ou class Diff plutôt que def zone(tm), puisque notre changement n’a pas lieu au sein de cette méthode. On peut néanmoins obtenir le résultat escompté en jouant sur le nombre de lignes présentées autour des lignes modifiées grâce à l’option -U ou --unified.

❯ git diff -U1
@@ -270,5 +270,5 @@ def coerce_classes(from, to)
       case
-      when from.class == Date # not is_a?(Date), it will catch DateTime
+      when from.instance_of?(Date)
         [coerce_date(from, to), to]
-      when to.class == Date
+      when to.instance_of?(Date)
         [from, coerce_date(to, from)]

On comprend alors que Git recherche le contexte autour du hunk et non pas strictement autour des lignes modifiées. Malgré cela l’utilisation de modèles de diff reste néanmoins utile et pertinente dans la grande majorité des cas !

Ressources