Comment minimiser la consommation en ressources mémoire et flux de données d'un clonage Git ? # Contexte Jean-Cloud est une petite association d'hébergement associatif sur du matériel de récupération. Elle lance en ce moment le projet Shlagernetes : un logiciel qui permet de répartir et gérer des services sur plusieurs serveurs de récupération. Git est utilisé dans certains cas pour installer un service sur un serveur ou le mettre à jour. # Objectif L’objectif est d'obtenir la dernière version (ou une version précise) d’un dépôt git, en utilisant le moins de ressources possible. Par ressources, on entend le flux de données qui part du remote pour arriver au dossier local, ainsi que la place mémoire occupée par le dépôt sur le serveur local. Le dépôt Git créé n'enverra aucune donnée au remote. Il n'aura aucune utilité de l'historique. Il pourra éventuellement conserver certains fichiers locaux en plus de ses clonages Git. En cas de conflit, le remote aura toujours raison. Il incluera les éventuels submodules. Il peut vouloir télécharger le dernier commit de HEAD( par défaut) ou bien un commit d'une certaine référence, c'est-à-dire branche ou tag. # Procédé Des tests sur différentes commandes ont été réalisés sur un dépôt factice. Le dossier de tests est transportable et peut être téléchargé ici. Attention, pour fonctionner en local, il nécessite d'autoriser le protocole pour les fichiers locaux : git config --global protocol.file.allow always. Ce n'est pas la configuration par défaut car elle peut représenter une faille de sécurité. Les tests consistent à analyser l'espace mémoire pris par le dépôt en local grâce à la commande bash "du", ainsi qu'à analyser le texte produit par Git lors du clonage. # Résultats finaux La combinaison finale retenue est : ## Pour cloner : git clone --depth=1 --recurse-submodules --remote-submodules --shallow-submodules depth=1 permet d'uniquement cloner le dernier commit et les objets nécessaires. Par défaut, elle est single-branch. recurse-submodules s'assure que le contenu des submodules est cloné remote-submodules s'assure que le contenu des submodules est cloné à partir du remote submodule originel shallow-submodules s'assure que seul le dernier commit des submodules est importé (pour que cela fonctionne en local, il faut préciser ://file/ avant le chemin des submodules) ## Pour mettre à jour : git submodule update --init --recursive --force --depth=1 --remote git fetch --progress --tags --depth=1 --prune --prune-tags origin git reset --hard origin/main git reflog expire --expire=now --all git gc --aggressive --prune=now [git clean -qfdx] git submodule update --init --recursive --force --depth=1 --remote init met à jour le fichier .gitmodules recursive applique la commande aux submodules de submodules etc. force permet d'ignorer les changements locaux aux submodules et d'automatiquement check out la nouvelle version depth=1 permet de considérer uniquement le dernier commit du submodule remote permet de mettre à jour depuis le remote submodule originel git fetch --tags --depth=1 --prune --prune-tags origin tags permet de fetch les tags, elle doit être précisée y compris si un tag est fetched par référence depth=1 permet de considérer uniquement le dernier commit prune permet de supprimer du dossier remote en local les références qui ne sont plus accessibles prune-tags permet non seulement de supprimer du dossier remote en local les références qui ne sont plus accessibles, mais aussi de supprimer les les tags locaux qui n'existent pas sur le remote git reset --hard origin/main git reflog expire --expire=now --all cette ligne permet de marquer tous les reflogs isolés comme expirés immédiatement au lieu de 90 jours plus tard. Cela permet un plus grand nettoyage par git gc. git rev-list permet de vérifier quels objets sont reliés et ne seront pas marqués comme expirés. git gc --aggressive --prune=now cette commande supprime les références non reliées et réorganise le dépôt pour l'optimiser. aggressive invoque repack et prend plus de temps. repack défait et refait les packs, qui sont des unités de compression. [git clean -qfdx] si cette commande est absente, les fichiers créés non-committés sont conservés. Cette combinaison ne permet de garder aucun changement effectué sur notre dépôt, hormis les créations de fichiers non-committés si git clean est omis. # Détails Voici un résumé de différentes pistes explorées afin de réduire l'empreinte du dépôt Git. ## Clone partiel (partial) vs superficiel (shallow) Un clone superficiel consiste à ne pas cloner tout l'historique du dépôt. Un clone partiel consiste à ne pas cloner tous les fichiers et/ou dossiers du dépôt, selon un filtre. Les filtres peuvent concerner les Binary Large Objects (blobs) ou bien les trees. Si le filtre concerne l'ancienneté, un clone partiel peut alors aussi être un clone superficiel. Les clones partiels peuvent être créés grâce à la commande git clone --filter. Lors de check-out ou switch, des objets initialement ignorés par le clone --filter peuvent être importés. Dans notre cas, nous ne souhaitons garder que le tout dernier commit, qui sera dans tous les cas laissé passer par git clone --filter et ce n'est donc pas pertinent. Les clones partiels peuvent aussi être créés par le sparse-checking. Certains fichiers et/ou dossiers n'apparaissent alors pas du tout dans le dossier local et ne sont pas concernés par les opérations git porcelain (de surface). Néanmoins, les objets associés à ces fichiers et dossiers sont toujours stockés dans le .git Un clone superficiel peut être grâce à l'option depth= qui indique le nombre de commits à conserver. Cette option est disponible pour la commande clone mais aussi fetch. ## Le large file storage LFS est une extension Git qui permet de manipuler les fichiers choisis (par nom, expression ou taille) à l'aide d'un cache local. En effet, les fichiers sont remplacés par des références dans le dépôt GIt et un dossier local hors du dépôt est créé pour stocker les fichiers. Ils y sont téléchargés de manière lazy, c'est-à-dire uniquement lorsqu'ils sont checked out. Toutes les anciennes versions sont stockées sur un serveur en ligne. C'est un mécanisme très intéressant, que nous ne retenons pas pour la même raison que le clone --filter : nous ne souhaitons garder que la toute dernière version des fichiers, qui serait dans tous les cas téléchargée par LFS. ## Suppression de l'historique L'usage de la commande git filter-branch est déconseillé par la documentation Git. Elle présente plusieurs failles de sécurité et de performance. Elle permet de réécrire l'historique des branches grâce à des filtres. La librairie Java repo-cleaner fonctionne, néanmoins la documentation Git estime que la librairie Python filter-repo est plus rapide et plus sûre. Nous ne souhaitons pas devoir installer Python ou Java donc ne creusons pas plus ces deux possibilités. Nous souhaitons supprimer tout l'historique sans filtrer, donc la commande git fetch --depth=1 suivie d'un git checkout, reset ou merge nous convient. ## checkout ? merge ? reset ? Une fois que l'on a fetched les modifications dans notre dossier local remote/, quelle est la meilleure façon de les appliquer à notre index et working directory ? Nous allons comparer 4 possibilités : git merge -X, git merge -s, git reset --hard, git checkout -f -B. Les résultats finaux sont identiques à l'exception de git merge -X. Dans le cas de git merge, on ne souhaite pas résoudre de conflits manuellement. Le remote doit toujours prévaloir sur les différences locales. ### git merge -X theirs Cette commande applique une stratégie ort qui en cas de conflit, donne la prévalence à theirs. Néanmoins, puisque l'on travaille en --depth=1, les deux branches n'ont pas d'ancêtre commun, et on doit d'ailleurs fournir l'option --allow-unrelated-histories. L'absence d'ancêtre commun empêche Git de reconnaître les similitudes à l'intérieur d'un même fichier. N'importe quelle modification d'un fichier tracé sur ours, même sur une nouvelle ligne, causera ainsi un conflit et sera écrasée. Cette commande permet tout de même de sauvegarder les fichiers nouvellement créés et committés sur ours. Les fichiers non-committés nouvellement créés sont conservés à moins que git clean soit run. Avantage : fichier commités créés sur ours conservés Inconvénient : en cas de délétion d'un fichier sur theirs qui existait déjà sur ours : il ne sera pas supprimé sur ours. ### git merge -s ours [attention les notions de theirs et ours sont inversées ici car git merge -s theirs n'existe pas] Cette commande applique une stratégie ours qui donne la prévalence à ours, qu'il y ait conflit ou pas. Elle va ignorer tous les changements et créations de fichiers committés sur theirs. Elle va également ignorer les modifications non committées. Les créations de fichier non-committées sont conservées à moins que git clean soit run. C'est le même résultat qu'avec la commande git reset --hard. Comme l'option git merge -s theirs n'existe pas, on doit faire une petite manipulation : #on veut merge origin/main sur main, en donnant la prévalence à origin/main #création d'une nouvelle branche temporaire temp que l'on check out, sourcée sur origin/main git switch -c temp origin/main #merge de main sur temp, en donnant la prévalence à temp qui est identique à origin/main git merge -s ours --allow-unrelated-histories main #on retourne sur main git checkout main #on merge temp. git merge --allow-unrelated-histories temp #suppression de temp git branch -D temp Avantage : Inconvénient : création d'une branche temporaire. ### git checkout –force -B main origin/main Cette commande est équivalente à git merge -s ours et à git reset --hard, à la différence près que l'on finit en detached HEAD state, ce qui n'est pas un problème dans notre cas puisque l'on ne souhaite pas push de modification depuis notre dépôt. Avantage : Inconvénient : detached HEAD state. ### git reset --hard git reset --hard est équivalente à git merge -s ours et git checkout --force -B. Avantage : Inconvénient : Les tests nous montrent que les options les plus économes en mémoire sont git checkout -force -B, git merge -s ours, git --reset hard, qui font la même chose. Néanmoins git reset --hard n'implique pas la création d'une branche temporaire et ne finit pas en detached HEAD state, c'est donc celle que nous retenons. ### Gestion des submodules Les submodules sont clonés au début via les options de git clone --recurse-submodules --remote-submodules. Puis ils sont mis à jour par git submodule update --init --recursive --force --depth=1 --remote. Les mêmes règles s'appliquent aux submodules qu'au reste du dépôt. Il est possible de préciser dans le fichier .gitmodules des règles d'import des submodules, comme un certain tag ou une certaine branche par exemple. En retirant --remote-submodules du git clone et --remote du git submodule update, alors les submodules seront identiques au dépôt que l'on clone et plus au dépôt originel du submodule. ##Tests ### Description du script Le script est composé de vingt-neuf tests (listés dans les résultats ci-dessous), qui s'appuient sur trois fonctions : generate_random_file, get_storage_used et get_bandwidth. generate_random_file s'appuie sur la commande bash dd et /dev/random. get_storage_used utilise la commande bash du. get_bandwidth récupère la sortie des commandes Git et extrait le trafic affiché. Celui-ci ne prend pas en compte le trafic des submodules. Les cinq premiers tests concernent le clonage. Les tests suivants concernent la mise à jour du dépôt selon différentes commandes, avec pour chaque commande trois cas : après addition d'un fichier, après délétion d'un fichier, après addition puis délétion d'un fichier. ### Extrait du README NAME performance_tests.sh SYNOPSIS performance_tests.sh [-a] [-h] [-n number] OPTIONS -a excutes all the tests. -n number executes test number -h prints the help. ### Résultats ======================================= Tests on the initial populating of the repository ============================================================= TEST0 TEST 0: classic cloning. memory usage: 22668 bandwidth usage (submodule excluded): 8.49 MiB ============================================================= TEST1 TEST 1: --single-branch cloning. memory usage: 22168 bandwidth usage (submodule excluded): 8.00 MiB ============================================================= TEST2 TEST 2: --depth=1 --no-single-branch memory usage: 17552 bandwidth usage (submodule excluded): 3.49 MiB ============================================================= TEST3 TEST 3: --depth=1 with single-branch (default)) memory usage: 17052 bandwidth usage (submodule excluded): 3.00 MiB ============================================================= TEST4 TEST 4: --depth=1 with single-branch (default) and reflog and gc HEAD is now at 23700cf adding submodule_for_performance_testing module memory usage: 17056 bandwidth usage (submodule excluded): 3.00 MiB ============================================================= TEST5 TEST 5 : sparse-checking only sample0 with depth=1 memory usage: 10060 bandwidth usage (submodule excluded): unknown ============================================ Tests on the updating of the repository ================================================= classic fetching+checking out ============================================================= TEST6 TEST 6: after addition of a 1M file memory usage: +2108 ============================================================= TEST7 TEST 7: after removal of a 1M file memory usage: -972 ============================================================= TEST8 TEST 8: after addition then removal of a 1M file memory usage: 1088 ============================================== fetching+checking out with --depth=1 ============================================================= TEST9 TEST 9: after addition of a 1M file memory usage: +2112 ============================================================= TEST10 TEST 10: after removal of a 1M file memory usage: -968 ============================================================= TEST11 TEST 11: after addition then removal of a 1M file memory usage: 48 ========================================= --depth=1 fetching+checking out reflog and gc ============================================================= TEST12 TEST 12: after addition of a 1M file memory usage: +2052 ============================================================= TEST13 TEST 13: after removal of a 1M file memory usage: -1020 ============================================================= TEST14 TEST 14: after addition then removal of a 1M file memory usage: 4 ================================================ --depth=1 fetching+ reset --hard ============================================================= TEST15 TEST 15: after addition of a 1M file memory usage: +2116 ============================================================= TEST16 TEST 16: after removal of a 1M file memory usage: -964 ============================================================= TEST17 TEST 17: after addition then removal of a 1M file memory usage: 52 ======================================= --depth=1 fetching+ reset --hard and reflog and gc ============================================================= TEST18 TEST 18: after addition of a 1M file memory usage: 2056 ============================================================= TEST19 TEST 19: after removal of a 1M file memory usage: -1016 ============================================================= TEST20 TEST 20: after addition then removal of a 1M file memory usage: 8 ============================ --depth=1 fetching+checking out after modification applied in submodule ============================================================= TEST21 TEST 21: after addition of a 1M file memory usage: 2112 ============================================================= TEST22 TEST 22: after removal of a 1M file memory usage: -976 ============================================================= TEST23 TEST 23: after addition then removal of a 1M file memory usage: 48 ==================================== --depth=1 fetching+merging -X theirs with reflog and gc ============================================================= TEST24 TEST 24: after addition of a 1M file memory usage: +2056 ============================================================= TEST25 TEST 25: after removal of a 1M file memory usage: 8 ============================================================= TEST26 TEST 26: after addition then removal of a 1M file memory usage: 8 ===================================== --depth=1 fetching+merging -s ours with reflog and gc ============================================================= TEST27 TEST 27: after addition of a 1M file memory usage: +2056 ============================================================= TEST28 TEST 28: after removal of a 1M file memory usage: -1016 ============================================================= TEST29 TEST 29: after addition then removal of a 1M file memory usage: 8