Vous vous demandez comment faire pour migrer un ensemble de dépôts Git en un seul dépôt ? Tout en conservant vos historiques de commits ?

Le contexte

Sur un de nos projet, nous avions choisi un découpage fort entre les couches de notre application : une librairie partagée, un backend dédié à la partie métier de l’application, un autre backend destiné à gérer les interactions avec des API tierces (Twilio, SI client…) et un dernier backend pour un front secondaire.

Ce choix architectural nous à naturellement orienté vers l’utilisation de plusieurs dépôts git. Nous pensions bien faire mais au bout d’un an le workflow de l’équipe a sévèrement souffert de ce choix.

Git repeat –hard *

Nous nous sommes rapidement rendu compte que les changements effectués dans le module commun avaient tendance à se propager aux autres modules. En conséquence nous étions obligés, pour chaque commit sur le dépôt commun de créer un commit reflétant ce changement sur les autres dépôts.

Comme nous travaillons avec git flow, la quantité d’opérations pour pousser un changement est vite devenue ingérable. Imaginez le scénario suivant :

  • A le dépôt du backend métier
  • B le dépôt du backend technique
  • C le dépôt du backend front
  • D le dépôt commun
  1. Je crée une nouvelle branche sur le dépôt A en vue d’implémenter une nouvelle feature :
    • A : git checkout -b feat/awesome-feature
  2. En cours de développement, je me rend compte que la librairie commune est impactée. Je dois donc créer une branche sur le dépôt D.
    • D : git checkout -b feat/awesome-feature
  3. En modifiant D je constate que B et C doivent être modifié également.
    • B : git checkout -b feat/awesome-feature
    • C : git checkout -b feat/awesome-feature

Vous commencez à voir la lourdeur de l’opération ? Il va sans dire qu’il faudra également se répéter dans les messages de commit, l’ajout à l’index …

Semver Hell

En plus de cette répétition constante digne du mythe de Sysyphe nous avons rencontré des problèmes de versioning.

La séparation des composants de l’application ne permettait pas de configurer un projet multimodule Maven, et donc d’utiliser la gestion de version automatique dans les modules de l’application.

En conséquence, à chaque release nous devions modifier la version de la dépendance au module commun. Pire, celle-ci pouvait différer du numéro de version du module.

Par exemple une release pouvait contenir les modules suivants :

  • A<1.2.1>
  • B<1.2.5>
  • C<1.2.3>
  • D<1.2.3>

En pratique nous n’avions aucun moyen direct de retrouver les différentes versions déployées à un instant T. Il fallait maintenir l’état des déploiement dans un tableur 😱

CI/CD

A ce stade vous l’avez compris, les problèmes de répétitions étaient également présents au niveau de notre solution d’intégration continue. Un job de build et de déploiement pour chaque module, une simple erreur de version et l’intégralité du déploiement est compromis.

La solution

fusion

Les plus malins d’entre vous ont probablement déjà envisagé l’utilisation de git-submodules. Pourquoi est ce une mauvaise solution ?

En utilisant submodules on ne s’affranchit pas du problème de répétition, il faudra gérer séparément nos flows git sur chaque dépôt. On pourrait gérer nos actions avec l’option --recurse en partant du dépôt parent mais cela poserait plusieurs problèmes : ça ne résout pas les problèmes de versioning; ce workflow exotique sera difficile à appréhender pour les nouveaux arrivant; par conséquent il est plus facile de faire des erreurs.

Comme disait ce bon vieux Bill :

“I choose a lazy person to do a hard job. Because a lazy person will find an easy way to do it.”

Bill Gates

La solution envisagée depuis longtemps était de fusionner nos quatre dépôts en un seul. Toute l’équipe s’accordait sur la nécessité de passer sur un dépôt unique mais la solution semblait difficile à mettre en place. En effet, migrer tout le projet implique d’arrêter les livraisons jusqu’à la fin de la migration git et du pipeline de CI/CD. En plus de cela nous pensions que la migration impliquait la perte de notre historique git (c’est faux !).

Nous allons voir comment résoudre partiellement ces problèmes :

Mise en place

Le rythme des sprints ne nous permet pas forcément de réaliser une telle migration à n’importe quel moment. Nous avons donc choisi d’attendre une baisse d’activité pour avoir le temps de réaliser l’opération. Pour un maximum de sérénité nous avons écrit un petit script afin de s’assurer qu’on pourrait reconstruire le dépôt dès que le moment opportun se présenterait.

Création du pom

Notre projet utilise Maven et nous avons besoin de la configuration la plus simple possible en bout de course. Les optimisations de gestions de dépendance et de build en multi-module viendrons dans un second temps. Ici on ne va s’intéresser qu’à la gestion de nos dépôts git.

On cherche simplement à pouvoir construire notre projet final avec une seule commande Maven.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>fr.ineat</groupId>
    <artifactId>mono-repo</artifactId>
    <version>1.14.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <modules>
        <module>repo-a</module>
        <module>repo-b</module>
        <module>repo-c</module>
        <module>repo-d</module>
    </modules>

</project>

Initialisation des variables

Dans un premier temps on va avoir besoin de définir comment on veut nommer nos dossiers de sous-module Maven et de variabiliser les dépôts à ajouter :

#!/bin/bash

mkdir monoreo
cd monorepo

repo_A_git="git@bitbucket.org:ineat/repo-a.git"
repo_A_target_name="repo-a"
repo_B_git="git@bitbucket.org:ineat/repo-b.git"
repo_B_target_name="repo-b"
repo_C_git="git@bitbucket.org:ineat/repo-c.git"
repo_C_target_name="repo-c"
repo_D_git="git@bitbucket.org:ineat/repo-d.git"
repo_D_target_name="repo-d"

Initialisation du dépôt

La première phase de l’opération consiste à initialiser le dépôt final avec le contenu du dépôt d’un de nos sous projets. On déplace l’intégralité du projet dans un sous dossier à l’exception du dossier .git/.

git init
git remote add "$repo_A_target_name" "$repo_A_git"
git pull "$repo_A_target_name" develop
git checkout develop

mkdir "$repo_A_target_name"
mv `ls -1a | grep -v "$repo_A_target_name" | grep -v ".git"` "$repo_A_target_name"
git add .
git commit -m "chore(monorepo): prepare $repo_A_target_name for migration"
git remote remove "$repo_A_target_name"

Fonction de migration

Plutôt que de répéter la migration pour chaque dépôt, on va définir une petite fonction qui prendra en paramètre l’adresse distante du dépôt et le dossier cible dans notre projet multi-module.

Par défaut git merge refuse de fusionner des historiques qui n’ont pas d’ancêtre commum, pour palier à cela on utilise l’option --allow-unrelated-histories. On utilise également l’option de merge --no-ff, en effet il ne sera pas possible de fast-forward deux historiques git complètement séparés.

Une fois la fusion réalisé on déplace l’intégralité des fichiers dans un sous répertoire en prenant soin d’exclure le dossier.git/ et les autres sous modules. On commit nos changement et on supprime le dépôt distant.

merge_repo() {
    repo_addr=$1
    target_name=$2
    git remote add "$target_name" "$repo_addr"
    git fetch "$target_name"
    git merge "$target_name"/develop --allow-unrelated-histories --no-ff
    mkdir "$target_name"
    mv `ls -a1 | grep -v ".git" | grep -v "$repo_D_target_name" | grep -v "$repo_C_target_name" | grep -v "$repo_B_target_name" | grep -v "$repo_A_target_name" | grep -v "'.'" | grep -v "'..'"` "$target_name"
    git add .
    git commit -m "chore: merge repo $target_name"
    git remote remove "$target_name"

}

On passe à l’action

Maintenant qu’on a définit notre fonction il ne reste plus qu’à l’appeler pour chaque dépôt (à l’exception de celui choisit à l’initialisation).

merge_repo $repo_D $repo_A_target_name
merge_repo $repo_B $repo_A_target_name
merge_repo $repo_C $repo_A_target_name

Nettoyage

Une fois qu’on a fusionné tout nos dépôts c’est le moment de nettoyer un certain nombre de fichiers qui ne seront plus utiles dans nos sous-dépôts. Dans notre cas on déplace la configuration .editorconfig à la racine du projet et on se débarrasse des fichiers de documentation générés lors de la phase de build. Vous avez peut être une configuration travis ou jenkins ? C’est le moment de se demander ce qui mérite d’être déplacé à la racine du projet.

mv $repo_A_target_name/.editorconfig .
rm ./*/.editorconfig
rm ./*/context_diagram.dot
rm ./*/context_diagram.png
rm ./*/context_diagram.html
rm ./*/glossary.md

git add .
git commit -m "remove subrepo generated doc"

cp ../mono_pom.xml ./pom.xml

git add .
git commit -m "chore(monorepo): add parent pom.xml"

Pensez à garder les .gitignore dans chaque sous module pour préserver leurs spécificités.

Finalisation

Avant toute chose, assurez vous que tout s’est bien passé avec un petit tree -L 3. Lancez également votre commande de build/test (dans notre cas mvn package).

C’est le moment de tagger notre dépôt. On a conservé l’historique de chacun mais il vous sera impossible de revenir à un état antérieur à la fusion que l’on vient de réaliser. Vous pouvez vous en convaincre rapidement avec la commande git log --graph.

Et après ?

Si tout s’est bien passé, vous êtes l’heureux possesseur d’un dépôt unique.

Maintenant que nos modules sont hébergés sur le même dépôt, nous allons pouvoir profiter pleinement des avantages d’un projet modulaire.

Attention cependant, il nous reste encore un certain nombre d’actions à réaliser avant d’être prêt pour la prod.

  • changer les poms pour déplacer la gestion de dépendances et la phase de build au niveau du module parent
  • modifier la CI/CD en conséquence. La bonne nouvelle, c’est que maintenant vous n’avez besoin que d’un seul job pour gérer vos déploiements.

Conclusion

Avec un peu de scripting, nous sommes passé facilement de quatre à un seul dépôt git et nous allons pouvoir gérer plus efficacement notre cycle de vie produit. C’est le couplage fort entre les composants de notre projet qui nous a poussé à faire ce choix. Gardez à l’esprit que cette solution n’est pas adapté à tous les projets.

Si vous ne souhaitez pas faire du mono-repo mais que vous avez ces problématiques de synchronisation, il existe des outils qui pourraient vous faciliter la tâche :

  • tsrc (nécessite un runtime python)
  • GR (nécessite un runtime NodeJs)
  • gita (nécessite un runtime python)