Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture de hawser

hawser est un gestionnaire d’espace de travail multi-dépôts. Il épingle une flotte de dépôts git à des commits exacts, pilote leur cycle de vie PR/MR sur GitHub et GitLab, et présente toute la flotte à travers un cockpit ratatui orienté clavier (le binaire haw). Ce document décrit comment le code est réellement organisé — les frontières entre crates, le modèle de concurrence et les invariants qui le maintiennent testable.

L’objectif de conception qui façonne tout ce qui suit : la logique métier ne fait jamais d’E/S qu’elle ne peut simuler. Les effets de bord git traversent le trait GitBackend, les appels de forge traversent le trait Forge et les effets de bord du TUI traversent le trait Controller. Chaque couture a une implémentation de production et un faux de test, de sorte que l’essentiel du code est testé unitairement sans réseau ni vrai git.

1. Vue d’ensemble

L’espace de travail (Cargo.toml, resolver = "3", édition 2024, unsafe_code = "forbid" sur tout l’espace de travail) est un ensemble de petites crates. haw-core détient le modèle de domaine et ne dépend de rien d’autre que des crates de sérialisation. Tout le reste en rayonne. Le binaire hawser (livré sous le nom haw) est la seule crate qui relie les pièces entre elles et le seul endroit où anyhow est autorisé.

                    +----------------------+
                    | haw-tui              |   depends ONLY on
                    | (ratatui cockpit)    |   ratatui + nucleo + haw-core types
                    +----------+-----------+   — no git, no network.
                               | Controller trait (side-effect seam)
                               v
+---------+   +----------+   +----------+   +-----------+   +-----------+
| hawser  |-->| haw-core |   | haw-git  |   | haw-forge |   | haw-merge |
| (bin)   |   | (domain) |<--| ShellGit |   | Forge     |   | slice/seal|
+----+----+   +----------+   +----------+   +-----------+   +-----------+
     |            ^   ^            (impl of        (octocrab /
     |            |   |         GitBackend)         reqwest)
     +---> haw-git, haw-forge, haw-merge, haw-tui  (binary wires them all)

haw-plugin ....... SDK for out-of-process `haw-<name>` plugin binaries
haw-artifact ..... plugin: SLSA/in-toto provenance + signing
haw-compliance ... plugin: CycloneDX 1.5 + SPDX 2.3 SBOM
haw-git-gate ..... plugin: secret/hygiene gate (gitleaks or heuristic)
xtask ............ release/packaging automation (`cargo xtask dist`)

haw-core n’a aucune dépendance envers haw-git, haw-forge ou haw-tui : il déclare le trait GitBackend (haw-core/src/git/mod.rs) et laisse les appelants injecter une implémentation. haw-git dépend de haw-core et implémente ce trait ; la flèche pointe de l’implémentation vers le trait.

2. Responsabilités des crates

CrateResponsabilitéTypes publics clés
haw-coreModèle de domaine, sans parti pris d’E/S. Manifeste → lock → état de l’espace de travail, résolution, snapshots, journal d’audit, dispatch de plugins.manifest::Manifest, lock::Lockfile / LockedRepo, workspace::Workspace / RepoStatus / SyncPlan / RepoTask, resolver::ResolvedRepo, git::GitBackend (trait)
haw-gitGitBackend de production : délègue au git de l’utilisateur. Assistant de fan-out borné.ShellGit, parallel::fan_out
haw-forgeOrchestration PR/MR + CI derrière le trait Forge ; cycle de vie des changesets agnostique de la forge.Forge (trait), github::GitHub, gitlab::GitLab, ForgeFactory / Tokens, ForgeError, OpenPr / CiRun / PrStatus
haw-mergeFusion collaborative optionnelle de style mergetopus : découpe une fusion riche en conflits par chemin de premier niveau, résout pièce par pièce, scelle en un seul commit sur une branche d’intégration.MergeBackend (trait), git::GitMerge
haw-tuiLe cockpit haw. Rend et dispatche ; ne connaît rien de git ni du réseau.Controller (trait), run, Snapshot, FleetPr / FleetCiRun, Exit
haw-pluginSDK pour créer des binaires de plugin haw-<name> hors processus (contexte haw.plugin/1 en entrée / haw.plugin.report/1 en sortie).run, Report
hawserLe binaire haw. CLI Clap, relie core+git+forge+merge+tui, détient anyhow et la surface d’erreur actionnable.CliController, DemoController, main
xtaskRelease/packaging : construit un binaire de release, archive sous dist/, affiche le SHA-256 pour la formule Homebrew / le manifeste Scoop.main

3. La frontière Controller

Les dépendances de haw-tui sont ratatui, nucleo-matcher et haw-core (pour les types de données simples qu’il rend). Il n’a aucune dépendance envers haw-git, haw-forge ou tokio. Chaque effet de bord dont le cockpit a besoin — rafraîchir le statut, récupérer les PR, fusionner, basculer sur une branche, lire un arbre de fichiers — est une méthode de Controller (haw-tui/src/lib.rs:351) :

#![allow(unused)]
fn main() {
pub trait Controller: Send {
    fn snapshot(&mut self) -> io::Result<Snapshot>;
    fn fleet_prs(&mut self) -> io::Result<Vec<FleetPr>>;
    fn pr_merge(&mut self, repo: &str, number: u64) -> io::Result<String>;
    fn pr_checkout(&mut self, repo: &str, number: u64) -> io::Result<String>;
    fn repo_tree(&mut self, repo: &str, subpath: &str, remote: bool) -> io::Result<Vec<FileEntry>>;
    fn file_content(&mut self, repo: &str, path: &str, remote: bool) -> io::Result<String>;
    // ~25 verbes au total ; tous renvoient io::Result et tous sont Send.
}
}

C’est une inversion de dépendance délibérée. Le TUI est au sommet du graphe et ne dépend que d’une abstraction ; le binaire fournit l’implémentation concrète :

  • CliController (hawser/src/main.rs:2295) est l’implémentation de production. Chaque méthode ouvre un Workspace, construit un backend ShellGit et/ou un client Forge, et exécute les mêmes chemins de code que les sous-commandes CLI (p. ex. sync_filtered réutilise Workspace::plan_sync + fan_out).
  • DemoController (hawser/src/main.rs:3052) renvoie des données préfabriquées en mémoire et n’atteint aucun espace de travail, git ni réseau. haw dash --demo rend chaque vue de manière déterministe pour les enregistrements GIF et, surtout, pour les tests.

Parce que la couture est un objet trait (Box<dyn Controller> passé à haw_tui::run), les 59 tests unitaires de haw-tui construisent des fixtures App et vérifient les spans rendus ainsi que les Job dispatchés vers le canal du worker — pas de terminal, pas de git, pas de sockets. La logique du cockpit est exercée sans interface graphique.

4. Modèle de concurrence

Le cockpit est mono-thread pour le rendu et pilote tout le travail bloquant sur un thread worker dédié unique. Il n’y a aucun async dans l’UI.

Pourquoi pas du tout async. Les clients de forge et les délégations au shell git sont intrinsèquement bloquants ; rendre toute l’UI async n’apporterait qu’un runtime et des fonctions colorées. À la place, la boucle de rendu reste synchrone et décharge le travail bloquant sur un thread, en communiquant via deux canaux std::sync::mpsc.

Le worker. haw_tui::run (haw-tui/src/lib.rs:886) crée un canal Job et un canal Outcome, puis spawn_worker (:914) déplace le Box<dyn Controller> sur un std::thread::spawn. Le worker est une boucle séquentielle : while let Ok(job) = jobs.recv(), faisant correspondre chaque Job à un appel Controller et renvoyant un Outcome. La sérialisation est une fonctionnalité : entrer dans une vue pendant qu’un job est en cours enfile tout de même la lecture ; le worker l’exécute après le job courant (commentaires à :1005, :1144), de sorte que la navigation n’est jamais refusée.

Les enums de canal (:426, :463) :

#![allow(unused)]
fn main() {
enum Job {
    Refresh, ChangesetPrs(String), FleetPrs, FleetCi, Governance,
    RepoDetail(String), PrDetail(String, u64), CiDetail(String, u64),
    PrDiff(String, u64), CiLogs(String, u64),
    RepoTree(String, String, bool), FileContent(String, String, bool, String),
    Action(&'static str, ActionKind),   // verbes à effet de bord (sync, land, merge, ...)
}

enum Outcome {
    Snapshot(Box<io::Result<Snapshot>>), FleetPrs(Box<io::Result<Vec<FleetPr>>>),
    Detail(String, Box<io::Result<String>>),   // exploration détaillée partagée (git de dépôt / PR / CI)
    Tree(Box<io::Result<Vec<FileEntry>>>),
    Action(&'static str, io::Result<String>),
    // ...
}
}

Les résultats sont boxés pour garder l’enum petite malgré des charges utiles volumineuses (snapshots, diffs). L’étiquette &'static str sur Action traverse sans modification pour que le gestionnaire d’outcome sache ce qui s’est achevé.

Rester réactif. La boucle d’événements (event_loop, :1345) ne bloque jamais sur le worker. À chaque itération : vider outcomes.try_recv() (non bloquant) et appliquer les résultats, rafraîchir automatiquement et de manière opportuniste en cas d’inactivité (cadence de 5 s, supprimée pendant la saisie/les overlays/le travail en cours, :1525), dessiner une image, puis event::poll(Duration::from_millis(120)) pour la saisie. app.busy: Option<&'static str> conditionne un spinner et empêche le double-dispatch ; il est effacé à l’arrivée de l’Outcome correspondant. Les vues réseau (Prs/Ci/Governance) sont strictement à la demande — le rafraîchissement automatique en cas d’inactivité ne touche que le snapshot de statut local.

Séparément, le travail CLI inter-dépôts (sync, run) utilise haw_git::parallel::fan_out (haw-git/src/parallel.rs) : fan-out borné entre dépôts avec un simple std::thread::scope et un index atomique partagé, jobs.clamp(1, items.len()) workers. Pas de tokio là non plus.

Le piège runtime.enter() d’octocrab

Forge est un trait synchrone, mais octocrab est async. github::GitHub détient un runtime privé tokio::runtime::Builder::new_current_thread().enable_all().build() (haw-forge/src/github.rs:16) et appelle runtime.block_on(...) pour chaque requête — une façade synchrone au-dessus d’un client async, un seul thread worker, aucun runtime global partagé.

Le bug subtil contre lequel cela protège : construire le client octocrab n’est pas lui-même un await, mais en interne il lance une tâche worker tower::buffer, et ce spawn panique avec « no reactor running » s’il n’y a pas de réacteur Tokio actif dans le contexte du thread courant. block_on n’établit ce contexte que pour la future qu’il pilote — pas pour l’appel synchrone builder.build(). Le correctif est la garde dans client (github.rs:32) :

#![allow(unused)]
fn main() {
// le client d'octocrab lance un worker tower::buffer au build, qui a besoin d'un
// réacteur Tokio actif ; entrer dans le runtime pour que le spawn ne panique pas.
let _guard = self.runtime.enter();
builder.build()...
}

runtime.enter() renvoie un EnterGuard qui installe le réacteur pour la portée courante, de sorte que le spawn tower::buffer trouve un réacteur. Un test de régression dédié construit le client avec un jeton vide et sans réseau pour éviter que cela ne se casse à nouveau silencieusement (github.rs:626, client_builds_inside_runtime_without_panic).

5. L’abstraction Forge

Forge (haw-forge/src/lib.rs:130) est un seul trait avec deux implémentations de production :

  • github::GitHuboctocrab (REST v3) sur le runtime privé current-thread décrit ci-dessus. Prend en charge github.com et Enterprise (base /api/v3).
  • gitlab::GitLabreqwest::blocking::Client contre REST v4. Aucun runtime nécessaire ; les MR se mappent sur le vocabulaire PR neutre vis-à-vis de la forge.

ForgeFactory::client_for (impl Tokens, :273) choisit l’implémentation à partir de la clé forge = explicite du manifeste si elle est présente, sinon par sous-chaîne d’hôte de l’URL (detect, :347), lit les jetons depuis les variables d’environnement conventionnelles (en se rabattant sur un gh auth token connecté, :261), et renvoie un Box<dyn Forge>.

Liste bon marché vs exploration détaillée. Le trait se scinde délibérément en scans de flotte bon marché et explorations détaillées coûteuses, de sorte que les vues de flotte se chargent vite et que le détail n’est récupéré qu’à Enter :

  • list_open_prs / list_ci_runs — un appel borné par dépôt, plafonné à OPEN_PRS_LIMIT = 25 / CI_RUNS_LIMIT = 15 pour garder le nombre de requêtes borné sur les dépôts actifs. Renvoie des lignes OpenPr / CiRun neutres vis-à-vis de la forge.
  • pr_detail / ci_run_detail / pr_diff / ci_logs / file_blob — les récupérations d’exploration détaillée, chacune renvoyant du texte brut plafonné par DIFF_LINE_CAP = 600, LOG_LINE_CAP = 800, FILE_LINE_CAP = 600 via cap_lines (qui ajoute une note « truncated, N more line(s) »).

Gestion des types de média. octocrab décode le JSON, mais les diffs, les blobs bruts et les logs sont du texte brut. GitHub::get_text (github.rs:77) contourne octocrab avec un petit GET reqwest bloquant portant un en-tête Accept personnalisé et suivant les redirections, renvoyant Ok(None) sur 404 :

  • diffs unifiés : Accept: application/vnd.github.v3.diff (l’endpoint pulls renvoie le diff tel quel) ;
  • contenu de fichier brut : application/vnd.github.raw ;
  • logs de job Actions : servis via une redirection 302 vers une URL signée (les logs expirés apparaissent comme un message clair, pas comme une erreur).

Le contrat de rapport en texte brut (pas d’ANSI ; l’appelant applique le style) est ce qui permet aux mêmes chaînes de détail de s’afficher à l’identique dans le CLI et dans la vue détaillée défilable du TUI.

6. Modèle de reproductibilité

Le contrat central est un pipeline à trois étapes : manifeste → lock → état.

  1. haw.toml (manifeste)manifest::Manifest (model.rs:16) : remotes, dépôts, stacks, overlays. Intention rédigée par l’humain. Le rev d’un dépôt est une référence branche/tag/sha.
  2. haw.lock (lockfile)lock::Lockfile (lock/mod.rs:34), LOCK_VERSION = 1, #[serde(deny_unknown_fields)]. Généré par machine. Chaque LockedRepo épingle rev (le SHA résolu exact), source_rev (la référence de manifeste dont il a été résolu) et branch (les dépôts ne sont jamais laissés détachés). Le lock couvre tous les dépôts du manifeste, pas seulement un stack — de sorte que changer de stack ne réécrit jamais le lock ; les overlays ne prennent effet qu’à la régénération.
  3. État de l’espace de travail — le répertoire .haw/ : stack courant, snapshots, journal d’audit. Workspace (workspace/mod.rs:54) lit le manifeste + lock et planifie la synchronisation (plan_syncSyncPlan de RepoTask ciblant chaque SHA verrouillé).

Le lock est l’artefact de preuve/d’audit. Il est déterministe et uniquement LF — un test golden (hawser/tests/golden.rs::lockfile_is_deterministic_and_lf_only) vérifie des octets identiques sur deux exécutions avec les mêmes entrées, pas de CRLF, saut de ligne final. Ce déterminisme est ce qui rend le lock committable et comparable comme enregistrement de provenance de build ; haw-compliance et haw-artifact consomment directement les rev épinglés pour émettre les SBOM et la provenance SLSA.

Dérive. RepoStatus (workspace/mod.rs:99) porte head (le HEAD réel du dépôt), locked_rev (ce que dit haw.lock) et drift: bool — vrai lorsque HEAD diffère du rev verrouillé. Workspace::status le calcule par dépôt ; haw status/haw verify et la grille de flotte du cockpit l’exposent. pin (:257) fait l’inverse : réécrit le lock à partir des HEAD courants (sans réseau), transformant l’état de travail en nouvelle vérité épinglée.

7. Gestion des erreurs

Stratégie à deux couches, scindée proprement à la frontière du binaire :

  • Les bibliothèques utilisent des erreurs typées via thiserror : ForgeError (haw-forge/src/lib.rs:116MissingToken, UnknownForge, Api, …), LockError, WorkspaceError / SyncError, GitError, ManifestError, MergeError. Les appelants peuvent faire un match sur la variante. Le trait Controller réduit celles-ci à io::Result à la couture du TUI (io::Error::other), car le cockpit ne fait jamais que rendre le message.
  • Le binaire utilise anyhow. hawser est la seule crate qui dépend d’anyhow ; run() renvoie anyhow::Result et ajoute .context(...) aux sites d’appel. main (main.rs:609) affiche le error: de premier niveau, parcourt err.chain().skip(1) pour les causes et — la partie actionnable — exécute hint_for (:632) sur le texte d’erreur en minuscules pour attacher un correctif d’une ligne : pas de manifeste → haw init ; jeton manquant → définir HAW_GITHUB_TOKEN / gh auth login ; « drift »/« lock » → haw sync ; « not a git repo » → haw sync pour cloner.

Les lints de l’espace de travail (Cargo.toml) règlent clippy::unwrap_used et clippy::expect_used sur warn dans tout l’espace de travail ; les modules de test réactivent l’usage avec #![cfg_attr(test, allow(...))].

8. Tests

~173 tests, stratifiés pour correspondre aux coutures :

  • Purs/unitaires dans chaque crate : haw-core (édition de manifeste, allers-retours de lock, cycle de vie des changements, resolver, snapshots), haw-forge (repo_coords pour chaque forme d’URL, detect, cap_lines, progress_bar).
  • haw-tui (59 tests) pilotent des fixtures d’état App (p. ex. fleet_app()) et vérifient deux choses : le contenu/les couleurs des Span rendus par les helpers purs draw_*, et le Job exact dispatché sur le canal du worker après un appui touche (rx.try_recv()Ok(Job::FleetPrs), etc.). Pas de terminal, pas de réseau — la couture Controller et le canal rendent tout le cockpit testable sans interface graphique.
  • FakeForge / FakeGit (haw-forge/tests/orchestrate.rs) : l’orchestration des changesets (request/status/land) s’exécute contre des faux en mémoire injectés via FakeFactory, de sorte que la logique de cycle de vie inter-dépôts est vérifiée sans HTTP.
  • Golden de bout en bout (hawser/tests/golden.rs) : construit de vrais dépôts git dans des répertoires temporaires, exécute le binaire haw réel et vérifie une sortie standard normalisée par rapport à des chaînes golden — tree, status + le contrat CI du code de sortie 3 pour dépôt sale, sync, le schéma JSON stable haw.status/1 et le déterminisme du lockfile. Ces tests s’exécutent sur la matrice CI, donc leur réussite signifie que le binaire livré se comporte comme attendu.

La triade Forge/GitBackend/Controller est la raison pour laquelle cette couverture est peu coûteuse : chaque dépendance coûteuse a un faux, et le seul endroit où elles sont reliées à de vraies E/S — hawser — est couvert par les tests golden du binaire.