Tentacule ou comment sauter de repo en repo en claquement de ventouse

Un poulpe bibliothécaire à écran de terminal sur la tête, entouré de livres flottants et de rayonnages en bois, gère des dépôts Git dans une bibliothèque magique et lumineuse.

Que ce soit côté pro ou perso, j'ai toujours besoin de naviguer entre plusieurs dépôts git, parfois venant de différentes forges (github.com, gitlab.com, gitlab d'entreprise, forge perso, etc.) et il faut l'avouer c'est un moment de friction… J'ai corrigé le souci de mon côté en créant un CLI en Rust (parce qu'envie de refaire du Rust en ce moment), je vous explique !

L'avant Tentacule🔗

J'ai cette problématique du multi-repo depuis un paquet d'année, et jusque-là j'avais opté pour un système très très basique : des alias bash.

Je maintenais donc à la main, un set d'alias du style

alias go-proj1=$(cd ~/path/to/proj1)
alias go-proj2=$(cd ~/path/to/proj2)
...

C'est pas passionnant à maintenir, c'est error-prone (comme c'est constamment des ajustements manuels, c'est facile de faire des typos), c'est un pavé qui prend pas mal de place dans mon .bashrc / .zshrc, il faut penser à l'ajouter à chaque nouveau clone… Mais on va pas se mentir : ça fonctionne très bien !

L'heure d'une vraie solution🔗

Ce que j'ai en tête c'est d'avoir un CLI qui va scanner tous les dépôts sur ma machine, et à la demande je veux pouvoir sauter directement dans le dossier d'un dépôt quand j'ai besoin sans avoir à réfléchir où je suis et/ou comment je m'y déplace.

Dans l'idée c'est facile, parcourir récursivement des dossiers, enregistrer certains chemins dans un fichier, puis pouvoir chercher dans le fichier. C'est super simple ! Finalement c'est une sorte de commande cd mais avec une autre manière de se déplacer.

Mais y'a un point compliqué : on ne peut pas créer un exécutable qui va changer le chemin courant de votre shell.

En effet, cd c'est une fonction dite "builtin" (votre shell peut vous le confirmer si vous faites type cd, vous devriez avoir cd is a shell builtin), c'est à dire que quand vous tapez cd truc, en fait votre shell (bash, zsh, etc.) va appeler directement une fonction interne au binaire qui gère votre shell, pas un programme externe. Donc vous ne pouvez pas remplacer juste cd via une autre commande. Donc il faut tricher.

J'ai envisagé deux options :

  • la première c'est créer une fonction bash qui appellera notre CLI tentacule et donnera la réponse à cd pour se déplacer, c'est pas forcément le plus clair en tant qu'utilisateur, mais c'est simple et ça fonctionne comme système ;
  • la seconde c'est de créer un shell, qui va intercepter certaines commandes et redonner à un autre shell le reste, je trouve ça encore moins simple à configurer, c'est super intrusif, ça peut introduire des bugs partout… mais ça doit fonctionner ;

Vous vous en doutez surement : j'ai pris la première option.

Donc pour configurer tentacule, il faut ajouter une ligne dans votre .bashrc / .zshrc :

function go { found="$(tentacule find ${@:1})";if [ $? -eq 0 ]; then cd $found;fi }

Si on l'écrit de manière formatée et avec des commentaires ça se comprend assez bien je trouve :

function go {
    # $@ = tableau avec tous les paramètres
    # ${@:1} = tableau avec tous les paramètres à partir de l'index 1
    #          (les tableaux en bash sont indéxé à partir de 0)
    # 
    # appeler tentacule find en lui passant les arguments qu'on 
    # avait passé à la fonction go, mais en retirant le premier
    # argument
    # 
    # donc "go one two" devient "one two" et est passé à la commande
    found="$(tentacule find ${@:1})"
    # $? = le code de retour de la commande précédente
    #      (ici c'est "tentacule find ...")
    #
    # si $? est égal à zéro, alors on cd dans le chemin contenu 
    # dans $found
    # sinon on ne fait rien (tentacule va afficher une erreur si le code
    # retour n'est pas 0)
    if [ $? -eq 0 ]; then
        cd $found
    fi
}

Plutôt simple non ?

Au cœur de la bestiole !🔗

Vous pouvez trouver tout le code ici, donc je ne vais pas vous montrer tout le code. J'ai juste envie de vous montrer quelques points que je trouve plutôt intéressant.

À noter que j'ai fait un choix très net dès le début : aucune dépendance. Pour ce projet, un peu par challenge personnel, je ne veux dépendre de rien, mais aussi par volonté de réduire l'effort de maintenance à long terme (aucune dépendance à mettre à jour, juste à recompiler si y'a des nouveautés intéressantes dans Rust et/ou sa toolchain).

En termes de fonctionnement global c'est assez simple :

  • prendre les paramètres de la commande ;
  • les donner à une brique qui va déduire la commande qu'on veut lancer et les paramètres associés ;
  • prendre cette commande et déduire la fonction qu'on veut lancer ;
  • lancer la fonction ;

C'est basico-basique mais ça fonctionne plutôt bien. Surtout avec le système d'enum de Rust qui permet nativement de faire des ADT (Algebraic Data Type) :

#[derive(Debug, PartialEq)]
pub enum Command {
    Help,
    Version,
    Index(IndexSubCommand),
    Find(FindSubCommand),
}

#[derive(Debug, PartialEq)]
pub enum IndexSubCommand {
    Help,
    Refresh,
    List,
}

#[derive(Debug, PartialEq)]
pub enum FindSubCommand {
    Help,
    Repo(Vec<String>),
}

Note : Vec<String> c'est équivalent en termes d'usage à un string[] (en TypeScript / JavaScript) ou List<String> (en Java).

Au premier niveau on a une Command, chaque option de Command a ou pas un paramètre qui est une sous-commande, chaque sous-commande est sur le même modèle. Sur chaque enum, on retrouve #[derive(Debug, PartialEq)] qui permet d'indiquer au compilateur qu'on veut que notre type dérive les traits Debug (permet d'avoir un affichage automatique pour du debug technique) et PartialEq (permet de comparer les valeurs avec un ==).

À partir de ça j'ai juste une fonction qui va inspecter le tableau des paramètres de la commande pour renvoyer la bonne commande.

Pour choisir le bon appel de fonction par rapport à la commande, j'ai quelque chose de très simple :

use crate::args::{Command, IndexSubCommand, FindSubCommand};

fn run_command(command: Command, root_dir: String) {
    match command {
        Command::Help => command::global_help::run(),
        Command::Version => command::version::run(),

        Command::Index(IndexSubCommand::Help) => command::index::help::run(),
        Command::Index(IndexSubCommand::Refresh) => command::index::refresh::run(root_dir).expect(""),
        Command::Index(IndexSubCommand::List) => command::index::list::run().expect(""),
        Command::Find(FindSubCommand::Help) => command::find::help::run(),
        Command::Find(FindSubCommand::Repo(words)) => command::find::repo::run(words).expect(""),
    }
}

Ici on commence à voir la force du combo ADT + pattern matching : cette fonction est très descriptive. match command va lancer du pattern-matching sur la commande, ensuite pour chaque valeur possible (en pouvant spécifier le pattern sur plusieurs niveaux, Command::Index(IndexSubCommand::Help) et Command::Index(IndexSubCommand::Refresh) sont deux patterns différents) je crée un pattern et je l'associe à un appel de fonction. Comme c'est un pattern-matching sur une enum, si j'oublie un cas, ça ne compilera pas, j'aurais une erreur et je devrais compléter pour continuer.

Avec Rust vous manipulez deux niveaux de langage : le langage Rust lui-même et le langage de macro de Rust.

Voici un exemple simple qui montre ça :

pub fn get_app_version() -> String {
    env!("CARGO_PKG_VERSION").to_string()
}

Cette fonction vise à renvoyer la version de l'application. Donc on déclare une fonction tout ce qu'il y a de plus simple : pas de paramètre, retourne une chaîne de caractère. Par contre vous noterez l'appel à env!(), et ce n'est pas une typo d'avoir un ! après le nom de la fonction, c'est qu'on est pas face à un appel de fonction Rust mais un appel de macro. En fait env!("CARGO_PKG_VERSION") sera exécuté / interprété par le compilateur puis remplacé par "0.1.0" (car dans mon cas, quand j'écris ces lignes, j'ai version = "0.1.0" dans mon Cargo.toml).

Donc après la passe d'exécution des macros, la même fonction devient :

pub fn get_app_version() -> String {
    "0.1.0".to_string()
}

C'est très puissant ce système de macro, car ça permet d'avoir pas mal de chose qui sont résolues pendant la compilation, pour permettre des choses plus efficaces à l'exécution. Je vous encourage à aller jeter un coup d'œil à mon logger que j'ai écrit avec des macros.

Pour finir je voudrais parler des tests : pas besoin d'un outil externe, tout est inclus dans la toolchain pour des tests courants. Pas non plus d'obligation de séparer les tests du code métier dans des fichiers séparés. Dans le même fichier que celui qui défini les enums Command, IndexSubCommand et FindSubCommand, j'ai à la fois la fonction get_command qui va parser les arguments et les tests associés dans un module dédié aux tests avec une annotation de configuration de test #[cfg(test)] permettant d'indiquer au compilateur de supprimer ce code pour la release.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_unknown_command_return_global_help() {
        assert_eq!(
            get_command(vec![String::from("i_do_not_exist")]),
            ( Command::Help, Flags { log_level: LogLevel::Warn } )
        );
    }

    // ...
}

Sinon à moins de n'avoir jamais fait de test unitaire, c'est assez limpide comment fonctionne ce test, il faut simplement s'habituer à ce que tout soit en snake_case en Rust (j'avoue plutôt utilisé du camelCase au quotidien faisant surtout du TypeScript et Java).

Demo time !🔗

Passons au plus intéressant : ça donne quoi en pratique ?

Démonstration de tentacule avec un refresh de l'index et une navigation
Démonstration de tentacule avec un refresh de l'index et une navigation

On ne peut pas faire grand-chose avec tentacule, mais on peut faire l'essentiel :

  • tentacule help va rappeler l'ensemble des commandes ;
  • tentacule version va donner la version de tentacule ;
  • tentacule index refresh va vous permettre de scanner les dépôts git ;
  • tentacule index list va vous permettre d'afficher les dépôts précédemment scannés ;
  • tentacule find <foo> va vous permettre de rechercher dans l'index une entrée qui matcherait le(s) mot(s) que vous passez ;

La force du truc à mon avis c'est qu'on va matcher le path complet, sans tenir compte de la case.

Par exemple, avec cet index :

/home/anthony/Projet/tentacule
/home/anthony/Projet/blog
/home/anthony/Projet/star-blog
/home/anthony/Projet/OctoDeps

On aura ce genre de réponse :

> tentacule find tentacule # ici on match exactement le nom du dossier et il n'y a pas d'autres options
/home/anthony/Projet/tentacule

> tentacule find ten # ici on match partiellement le nom du dossier mais c'est le seul match
/home/anthony/Projet/tentacule

> tentacule find blog # ici "blog" est présent dans deux chemins différents donc on a une erreur
thread 'main' (4885) panicked at /home/anthony/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tentacule-0.1.0/src/main.rs:31:45:
: "Multiple matches: /home/anthony/Projet/blog, /home/anthony/Projet/star-blog"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

> tentacule find /blog # par contre avec "/blog" on a plus qu'une occurrence, donc ça fonctionne
/home/anthony/Projet/blog

> tentacule find blog star # "blog" ça donne une erreur mais si on couple avec "star" on a bien un seul match
/home/anthony/Projet/star-blog

> tentacule find octo # ici on a un match car on ignore la case dans le path
/home/anthony/Projet/OctoDeps

> tentacule find Octo # ici on ne trouve rien, car on ignore pas la case sur les mots en entrée
thread 'main' (5009) panicked at /home/anthony/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tentacule-0.1.0/src/main.rs:31:45:
: "No repo found"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Ça vous donne une idée de l'état actuel du projet. C'est pas parfait, mais ça fonctionne !

Le futur de ce CLI🔗

Si vous me suivez, vous me connaissez, j'ai évidemment beaucoup d'idées pour la suite ! 😇

Déjà j'aimerais passer en 1.0.0 rapidement, il faut juste que je travaille quelques point avant :

  • ajouter plus de tests : il y a quelques tests mais pas assez à mon goût, j'aimerais pouvoir être bloqué à la moindre régression ;
  • avoir un pipeline sur Github Actions sur chaque PR et pour la release : côté PR ça permettrait d'être un peu plus structuré et de valider le code avant de basculer sur master, côté release ça permettrait d'avoir une automatisation de la publication avec tout ce qui va bien ;
  • avoir des releases définies sur Github pour pouvoir y attacher les binaires de release prêt à l'usage ;
  • retravailler les sorties d'erreurs : c'est simple côté code, mais côté utilisateur c'est moche et c'est pas propre ;
  • je vais renommer la fonction go en t ou to : le compilateur du langage Go c'est go donc ça rentrerait en conflit sur les machines des développeurs Go, donc autant changer maintenant… ;

Évidemment pas mal d'autres idées qui viendront plus tard.

J'ai en tête d'introduire un fichier de configuration pour paramétrer de manière globale tentacule — à priori définir le dossier racine (en plus de variable d'environnement), spécifier une liste de dossiers/fichiers à ignorer pendant le scan, activer/désactiver le scan des dossiers cachés (actuellement ils sont ignorés, mais ça pourrait être utile de pouvoir l'activer), peut-être des alias de chemin (soit vers des dépôts soit vers complètement d'autres dossiers) — à voir dans le temps comment ça évolue aussi.

J'ai aussi pensé à minima deux autres commandes. La première c'est tentacule clone <repo> qui viendrait cloner un dépôt dans un dossier spécifique, en suivant une certaine hiérarchie du type <root>/<domaine du repo>/<groups et/ou organisations>/<nom du repo>, permettant d'avoir un point central organisé pour ranger tous les dépôts qu'on clone, sans avoir à naviguer dans les dossiers avant de cloner. Comme ce serait une commande tentacule qui viendrait cloner (en appelant git dans le $PATH de l'utilisateur), on pourrait en profiter pour l'ajouter au passage dans l'index, et du coup on pourrait juste faire tentacule clone <repo> puis go repo. La seconde commande c'est tentacule pull (peut-être tentacule fetch dans la même logique) pour appliquer un git pull sur tous les dépôts de l'index d'un coup. C'est un peu bourrin, mais personnellement ça me servirait régulièrement de pouvoir faire ça donc j'y réfléchis.

En l'état, tentacule ne gère pas les worktrees, peut-être que ce serait utile de pouvoir le faire, peut-être que c'est une option qui serait à gérer sur tentacule clone pour setup correctement un worktrees facilement exploitable.

J'aimerais bien explorer l'idée d'un tentacule find --color pour mettre en évidence le match qui est fait pour aider à la prise en main. C'est très optionnel je pense, mais je trouve ça intéressant à développer de mon côté.

Enfin, j'aimerais trouver une solution pour me greffer sur le git clone et faire un refresh automatique à chaque appel. Peut-être pas utile si je fais tentacule clone mais à voir.

Un mot sur Rust🔗

J'en profite pour faire un petit laïus sur Rust, car je trouve ce langage super intéressant et plus je pratique, plus j'en suis convaincu !

Déjà c'est super facile de se lancer je trouve. On a pas une phase de mise en place compliqué : il suffit d'installer rustup (qui est une sorte nvm ou sdkman) en 1 ligne de shell, puis lui demander d'installer une toolchain (= suite d'outil pour travailler avec Rust) via rustup toolchain install et c'est bon tout est configuré. Pour créer un nouveau projet c'est simple cargo new <nom du projet>, pour exécuter cargo run, pour lancer les tests cargo test, pour formatter le code cargo fmt, pour avoir du lint cargo clippy, pour compiler cargo build, pour publier un crate (= package partagé avec les sources + la version compilée) cargo publish. Aucun setup préalable pour cette suite d'outils, si y'a un truc qui demande une configuration (comme cargo publish) on aura des messages clairs, des liens, des références pour nous guider. Je trouve ça vraiment incroyable d'avoir quelque chose d'aussi propre !

Côté performance on est très bon, c'est très très proche de ce qu'on aurait en C mais sans les problèmes du C : c'est ancien, c'est très peu cadré, on peut vite faire n'importe quoi (particulière avec la mémoire vu qu'on manipule beaucoup de pointeur), c'est difficile à mettre en place quand on se lance. Et le top c'est qu'on est face à un langage plutôt haut niveau, on retrouve du pattern matching, on a des choses comme des if expression, la syntaxe est légère, on travaille avec des streams (via .iter()) partout sur la manipulation de tableau donc quand on arrive de Java ou TypeScript c'est super naturel.

Le langage est assez rigide, mais on est bien guidé : il y a beaucoup de warnings et erreurs quand on essaie de compiler (cargo run va commencer par compiler, donc même chose), chaque warning/erreur donne des pistes de corrections et la documentation pour comprendre pourquoi c'est un problème ce qu'on a écrit. Globalement on aura plus de mal à compiler qu'avec d'autres langages, mais on sait que ça fonctionne à la sortie. Parce que Rust va vérifier énormément de chose pour s'assurer qu'on ait le moins possible d'erreur de gestion de mémoire.

J'en suis à me dire qu'avec le mouvement "tout codé avec l'IA" (pas d'accord mais bon…) Rust devrait devenir une référence du fait que tout est très explicite donc ça doit être très facile de travailler avec l'IA.

Conclusion🔗

J'ai envie de dire : encore un side project pour moi. C'est petit, ça va s'améliorer avec le temps, mais c'est super intéressant !

J'ai mis quelques heures pour avoir ce résultat, j'aurais pu aller plus vite vu le code avec de l'IA mais mon idée c'était me remettre à Rust, je m'en fiche de prendre du temps, je veux apprendre et comprendre ce que je fais pour maintenir ça dans le temps, pas juste cracher une application jetable. Par contre oui j'ai pris des raccourcis par moments, à commencer par le parcours des dossiers : aller fouiller la doc pour retrouver tous les appels à faire ça prend du temps, demander à Mistral une première version ça m'a pris 1min et j'ai pris le temps de tout restructurer complètement autrement pour que ça colle plus à la structure que je voulais.

Je suis content de ce que j'ai fait, j'avais envie de le partager, c'est fait. J'en reparlerai sûrement ! Au plaisir d'avoir des retours si vous testez !

Sources :

Crédit photo : Générée via Mistral AI avec le prompt suivant :

A giant, gentle octopus sitting cross-legged on a floating wooden desk, surrounded by ancient magical books representing Git repositories. Each book has kraft paper labels with project names like 'tentacule', 'blog', 'OctoDeps', and 'star-blog' written in elegant calligraphy. The octopus uses its eight tentacles to manipulate the books, with one tentacle holding a wooden quill writing in a central grimoire (the Tentacule index), and another pointing to a holographic screen with a wooden frame displaying a list of repositories. The background features an infinite wooden library with curved, gravity-defying shelves and floating books. Maple leaves (8-10, in red, orange, and yellow) fall gently around the scene. Warm, golden afternoon light illuminates the scene, with sunbeams passing through stained glass windows shaped like Git logos or Rust symbols. Soft glowing particles float around the books, symbolizing Rust processes. The octopus has a slightly furry texture with subtle printed circuit patterns on its skin, and its eyes resemble green monochrome terminal screens. A red panda (firefox) is curled up on a cushion in the bottom right corner (≤ 1/3 of the height), slightly turned in 3/4 view, with a golden halo and surrounded by maple leaves. A small Ghibli-inspired robot tidies books in the background. The overall style is Studio Ghibli, with soft lines, warm colors (beige, pale blue, warm orange, moss green), and a cozy, dreamlike atmosphere.