Tag Archives: git

Nettoyer l’historique d’une branche Git pour supprimer les fichiers indésirables

J’ai récemment eu à travailler sur un dépôt Git qui contenait des modifications à reporter sur un autre dépôt. Malheureusement, ce dépôt n’avait pas de fichier .gitignore au départ, si bien que de nombreux fichiers inutiles (répertoires bin/obj/packages…) avaient été archivés. Cela rendait l’historique très difficile à lire, puisque chaque commit contenait des centaines de fichiers modifiés.

Heureusement, Git permet assez facilement de "nettoyer" une branche, en recréant les mêmes commits sans les fichiers qui n’auraient pas dû se trouver là. Voyons donc pas-à-pas comment arriver à ce résultat.

Mise en garde

L’opération à réaliser ici consiste en une réécriture de l’historique, une mise en garde s’impose donc : il ne faut jamais réécrire l’historique d’une branche publiée partagée avec d’autres personnes. En effet, si quelqu’un d’autre crée des commits à partir de la version actuelle de la branche, et que celle-ci est réécrite, il deviendra beaucoup plus compliqué d’intégrer ces commits à la branche réécrite.

Dans mon cas, je n’avais pas besoin de publier la branche réécrite mais seulement de l’examiner en local, donc le problème ne se posait pas. Mais n’appliquez pas cette solution à une branche sur laquelle vos collègues travaillent, si vous tenez à conserver de bonnes relations avec eux 😉.

Créer une branche de travail

On va faire des modifications assez lourdes et potentiellement risquées sur le dépôt, il convient donc de prendre quelques précautions. Le plus simple dans ce genre de situation est tout bêtement de travailler sur une autre branche, pour ne pas risquer de faire des dégâts sur la branche originale. Par exemple, si la branche à nettoyer est master, on va créer une nouvelle branche master2 à partir de master :

git checkout -b master2 master

Identifier les fichiers à supprimer

Avant de lancer le nettoyage, il faut d’abord identifier les fichiers à supprimer. Dans le cas d’un projet .NET, il s’agit bien souvent du contenu des répertoires bin et obj (où qu’ils se trouvent) et packages (généralement à la racine de la solution), on va donc partir sur cette hypothèse pour l’instant. Les patterns des fichiers à supprimer sont donc les suivants :

  • **/bin/**
  • **/obj/**
  • packages/**

Nettoyer la branche : la commande git filter-branch

La commande Git qui va nous permettre de supprimer les fichiers indésirables s’appelle filter-branch. Elle est décrite dans le livre Pro Git comme "l’option nucléaire", car elle est très puissante et potentiellement dévastatrice… elle est donc à manipuler avec précaution.

Le principe de cette commande est de reprendre chaque commit de la branche, lui appliquer un filtre, et le recommiter avec les modifications causées par le filtre. Il existe plusieurs types de filtre, par exemple :

  • --msg-filter : permet de réécrire les messages des commits de la branche.
  • --tree-filter : permet de filtrer les fichiers au niveau de la copie de travail du dépôt (effectue un checkout de chaque commit, ce qui peut être assez long sur un gros dépôt)
  • --index-filter : permet de filtrer les fichiers au niveau de l’index (ne nécessite pas un checkout de chaque commit, donc plus rapide).

Dans notre scénario, --index-filter est parfaitement indiqué, vu qu’on souhaite simplement filtrer les fichiers par rapport à leur chemin. La commande filter-branch avec ce type de filtre s’utilise comme ceci :

git filter-branch --index-filter '<command>'

<command> désigne une commande bash qui sera exécutée pour chaque commit de la branche à réécrire. Dans le cas qui nous intéresse, ce sera simplement un appel à git rm pour supprimer de l’index les fichiers indésirables :

git filter-branch --index-filter 'git rm --cached --ignore-unmatch **/bin/** **/obj/** packages/**'

Le paramètre --cached indique qu’on travaille sur l’index et non sur la copie de travail; --ignore-unmatch permet d’ignorer les cas où aucun fichier ne correspond au pattern spécifié. Par défaut, la commande s’applique uniquement à la branche courante.

Pour peu que la branche ait beaucoup d’historique, la commande peut prendre assez longtemps à s’exécuter, il faudra donc s’armer de patience… Une fois terminé, vous devriez avoir une branche contenant des commits identiques à ceux de la branche d’origine, mais sans les fichiers indésirables.

Cas plus complexes

Dans l’exemple ci-dessus, il n’y avait que 3 patterns de fichiers à supprimer, donc la commande était assez courte pour être écrite "inline". Mais s’il y en a beaucoup plus, ou si la logique à appliquer pour supprimer les fichiers est plus complexe, ça ne tient plus vraiment la route… le plus simple est donc d’écrire un script (bash) qui contient toutes les commandes nécessaires, et de passer ce script en paramètre de git filter-branch --index-filter.

Nettoyer uniquement à partir d’un commit spécifique

Dans l’exemple précédent, on applique filter-branch à la totalité de la branche. Mais il est également possible de ne l’appliquer qu’à partir d’un commit spécifique, en spécifiant une plage de commits :

git filter-branch --index-filter '<command>' <ref>..HEAD

Ici, <ref> désigne une référence de commit (SHA1, branche ou tag). Notez que la fin de la plage de commits doit forcément être HEAD : on ne peut pas réécrire le début ou le milieu d’une branche sans toucher aux commits suivants, puisque le SHA1 de chaque commit dépend du commit précédent.

Méthodes C# dans les en-têtes de diff git

Si vous utilisez git en ligne de commande, vous aurez peut-être remarqué que les diffs indiquent souvent la signature de la méthode dans l’en-tête du bloc (la ligne qui commence par @@), comme ceci :

diff --git a/Program.cs b/Program.cs
index 655a213..5ae1016 100644
--- a/Program.cs
+++ b/Program.cs
@@ -13,6 +13,7 @@ static void Main(string[] args)
         Console.WriteLine("Hello World!");
         Console.WriteLine("Hello World!");
         Console.WriteLine("Hello World!");
+        Console.WriteLine("blah");
     }

C’est très pratique pour savoir où vous vous trouvez quand vous regardez un diff.

Git a quelques patterns d’expressions régulières prédéfinis pour détecter les méthodes dans quelques langages, y compris C#; ils sont définis dans userdiff.c. Mais par défaut, ces patterns ne sont pas utilisés… il faut dire à git quelles extensions de fichier sont associées à quel langage. Cela peut être spécifié dans un fichier .gitattributes à la racine de votre repo :

*.cs    diff=csharp

Cela fait, git diff devrait afficher une sortie similaire à l’exemple ci-dessus.

Est-ce que ça suffit ? Presque. En fait, les patterns pour C# ont été ajoutés à git il y a longtemps, et C# a pas mal évolué depuis. Certains nouveaux mots-clés qui peuvent maintenant apparaître dans une signature de méthode ne sont pas reconnus par le pattern prédéfini, par exemple async ou partial. C’est assez agaçant, parce que quand du code a changé dans une méthode asynchrone, l’en-tête du bloc dans le diff indique la signature de la précédente méthode non-asynchrone, ou la ligne où la classe est déclarée, ce qui prête à confusion.

Mon premier réflexe a été de soumettre une pull request sur Github pour ajouter les mots-clés manquants ; cependant j’ai vite réalisé que le repo de git sur Github est juste un miroir et n’accepte pas de pull requests… Le processus de contribution consiste à envoyer un patch à la mailing list de git, avec une longue et ennuyeuse liste de règles à respecter. Ce processus m’a semblé si laborieux que j’ai abandonné l’idée. Franchement, je ne sais pas pourquoi git utilise un processus de contribution aussi obsolète et compliqué, ça ne fait que décourager les contributeurs occasionnels. Mais c’est un peu hors-sujet, alors passons et voyons si on peut résoudre le problème autrement.

Heureusement, les patterns prédéfinis peuvent être redéfinis dans la configuration de git. Pour définir le pattern de signature de fonction pour C#, il faut définir le paramètre diff.csharp.xfuncname dans votre fichier de configuration git :

[diff "csharp"]
  xfuncname = ^[ \\t]*(((static|public|internal|private|protected|new|virtual|sealed|override|unsafe|async|partial)[ \\t]+)*[][<>@.~_[:alnum:]]+[ \\t]+[<>@._[:alnum:]]+[ \\t]*\\(.*\\))[ \\t]*$

Comme vous pouvez le voir, c’est le même pattern que dans userdiff.c, avec les backslashes échappés et les mots-clés manquants ajoutés. Avec ce pattern, git diff affiche maintenant la signature correcte pour les méthodes asynchrones :

diff --git a/Program.cs b/Program.cs
index 655a213..5ae1016 100644
--- a/Program.cs
+++ b/Program.cs
@@ -31,5 +32,6 @@ static async Task FooAsync()
         Console.WriteLine("Hello world");
         Console.WriteLine("Hello world");
         Console.WriteLine("Hello world");
+        await Task.Delay(100);
     }
 }

Ça m’a pris un moment de trouver comment le faire marcher, alors j’espère que ça vous sera utile !

Intégration avec Visual Studio Online + Git dans Team Explorer

J’ai commencé récemment à utiliser Visual Studio Online pour des projets personnels, et je dois dire que c’est une très bonne plateforme, même si ce serait bien de pouvoir héberger des projets publics et non pas seulement privés. J’apprécie particulièrement l’intégration dans le Team Explorer de Visual Studio pour gérer les tâches et les builds.

Cependant j’ai remarqué un petit bug quand on utilise Git pour le contrôle de version : le remote pour VS Online doit s’appeler origin, sinon Team Explorer ne détecte pas qu’il s’agit d’un projet VS Online, et n’affiche pas les pages “Builds” et “Work Items”.

Quand le remote VSO s'appelle "origin"Quand le remote VSO s'appelle "vso"

C’est clairement un bug (quoique mineur), car le nom origin est juste une convention, et un remote Git peut s’appeler n’importe comment ; je l’ai signalé sur Connect. Si vous rencontrez ce problème, vous pouvez le contourner en renommant le remote en origin :

git remote rename vso origin