Files
bions-rust/bion-git/src/lib.rs
cloudion-labo 2556698dd3 🦀 bions-rust vague 1 : 6 briques build-your-own-x — on ne les rebuild plus jamais
Principe RS-7 : « dès qu'on build un truc, plus personne n'a à le rebuild —
la seule chose à faire est l'optimisation. » (nexus/RepoVerse)
- bion-vc      : horloges vectorielles + MvReg fork-visible (LA spec
                 xion-relativiste-v0 enfin codée — CRDT testé par permutations)
- bion-triplet : l'Adressage Génératif (gen_hash BLAKE3, coords, résidu ;
                 résidu vide quand déjà-su ; align décidable au bit)
- bion-tsoinlog: journal append-only rejouable (CRC32 maison, crash-recovery)
- bion-kv      : magasin clé-valeur bitcask (compaction atomique, tombstones)
- bion-regex   : moteur Thompson NFA linéaire (jamais exponentiel — Russ Cox)
- bion-git     : mini-git content-addressed (SHA-1 maison + vecteurs officiels,
                 branches divergentes = le fork visible)
129 tests verts, clippy 0 warning, doc française = chaque bion est un cours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 01:07:31 +00:00

987 lines
38 KiB
Rust

//! # bion-git — un mini-git from scratch, le bion du principe « fork = liberté »
//!
//! Ce bion réimplémente **le noyau conceptuel de git** depuis les principes
//! (esprit *build-your-own-x*), en std-only : un magasin d'objets
//! **content-addressed** (blob / tree / commit), des commits chaînés qui
//! forment un graphe, et des branches qui ne sont que des noms posés sur
//! des commits.
//!
//! ## Le cours en trois minutes
//!
//! Git n'est pas un « gestionnaire de versions » : c'est un **graphe de
//! contenus immuables**. Trois types d'objets suffisent :
//!
//! - **blob** — le contenu brut d'un fichier, rien d'autre (pas de nom !) ;
//! - **tree** — un répertoire : une liste triée d'entrées `(mode, nom, id)`
//! qui pointent vers des blobs ou d'autres trees ;
//! - **commit** — un instantané : un tree racine, zéro ou plusieurs parents,
//! un auteur, un message.
//!
//! Chaque objet est nommé par le **SHA-1 de son contenu** (préfixé d'un
//! en-tête `"{type} {taille}\0"`). Conséquences magiques :
//!
//! - deux contenus identiques n'existent **qu'une fois** (déduplication) ;
//! - un objet ne peut pas être modifié sans changer de nom (immuabilité) ;
//! - un commit scelle *transitivement* toute son histoire : son id dépend
//! de son tree ET de ses parents, donc de tout le passé (chaîne de Merkle,
//! la même idée que les blockchains — git l'a fait en 2005) ;
//! - **forker est gratuit** : une branche n'est qu'un fichier de 41 octets
//! contenant un id. Deux branches divergentes partagent tout leur passé
//! commun sans copier un seul octet.
//!
//! Dans le vocabulaire du xerboxion : une branche est un **chemin** dans
//! l'espace des états, un fork une **bifurcation visible** — le principe
//! gelé par RS-7 (« divergence = branche, jamais d'écrasement silencieux »),
//! ici incarné par la structure de données elle-même. C'est le socle
//! conceptuel du nexus/RepoVerse : contribution = repo, reprise = fork.
//!
//! ## Écarts assumés avec le vrai git (documentés, pédagogiques)
//!
//! - **Pas de zlib** : les objets sont stockés *non compressés* dans
//! `.bgit/objects/ab/cdef…`. Le vrai git compresse chaque objet (deflate)
//! avant écriture — mais le hash porte sur le contenu *décompressé*, donc
//! nos identifiants de **blobs et trees sont exactement ceux de git**
//! (`git hash-object` donne les mêmes ids, vérifié dans les tests).
//! - **Commits** : même structure textuelle que git (`tree`/`parent`/
//! `author`/`committer` puis message), mais l'auteur est une chaîne libre
//! et le fuseau est figé à `+0000` — les ids de commits ne coïncident
//! donc avec git que si on reproduit son format d'auteur exact.
//! - **Pas d'index (staging), pas de packfiles, pas de merge automatique** :
//! [`Repo::write_tree`] photographie un répertoire directement, et le
//! graphe accepte plusieurs parents (le merge est *représentable*, sa
//! résolution est le travail d'un autre bion — voir `bion-vc` pour la
//! sémantique causale du fork visible).
//! - **Modes** : seuls `100644` (fichier) et `40000` (répertoire) sont
//! produits ; ni exécutables, ni symlinks.
//!
//! ## Exemple complet
//!
//! ```
//! use bion_git::{Repo, Kind};
//!
//! let dir = std::env::temp_dir().join(format!("bgit-doc-{}", std::process::id()));
//! std::fs::create_dir_all(dir.join("src")).unwrap();
//! std::fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
//!
//! let repo = Repo::init(&dir).unwrap();
//!
//! // Photographier le répertoire → un tree content-addressed.
//! let tree = repo.write_tree(&dir).unwrap();
//! let c1 = repo.commit(tree, &[], "premier instant", "rs-1").unwrap();
//! repo.branch("main", c1).unwrap();
//!
//! // Le fork visible : une deuxième branche sur le même commit.
//! repo.branch("fork", c1).unwrap();
//!
//! // Rematérialiser l'instantané ailleurs.
//! let dest = dir.join("restauré");
//! repo.checkout_tree(&tree, &dest).unwrap();
//! assert_eq!(std::fs::read_to_string(dest.join("src/main.rs")).unwrap(), "fn main() {}\n");
//! # std::fs::remove_dir_all(&dir).unwrap();
//! ```
pub mod sha1;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
/// Identifiant d'objet : le SHA-1 (20 octets) du contenu préfixé de son
/// en-tête. S'affiche en 40 caractères hexadécimaux, comme git.
///
/// C'est un *nom absolu* : le même contenu a le même [`Id`] dans tous les
/// dépôts de l'univers — c'est ce qui rend la réplication triviale
/// (pull/apply : on ne transfère que les ids manquants).
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Id(pub [u8; 20]);
impl Id {
/// Reconstruit un [`Id`] depuis ses 40 caractères hexadécimaux.
///
/// Erreur (`InvalidInput`) si la longueur ou un caractère est invalide.
pub fn from_hex(s: &str) -> io::Result<Id> {
let s = s.trim();
if s.len() != 40 {
return Err(bad_input(format!("id : 40 hex attendus, reçu {}", s.len())));
}
let mut out = [0u8; 20];
for (i, byte) in out.iter_mut().enumerate() {
let hi = hex_val(s.as_bytes()[2 * i])?;
let lo = hex_val(s.as_bytes()[2 * i + 1])?;
*byte = (hi << 4) | lo;
}
Ok(Id(out))
}
/// Les 40 caractères hexadécimaux de l'identifiant.
pub fn to_hex(&self) -> String {
sha1::to_hex(&self.0)
}
}
impl fmt::Display for Id {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl fmt::Debug for Id {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Id({})", self.to_hex())
}
}
impl FromStr for Id {
type Err = io::Error;
fn from_str(s: &str) -> io::Result<Id> {
Id::from_hex(s)
}
}
fn hex_val(c: u8) -> io::Result<u8> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(bad_input(format!(
"caractère hex invalide : {:?}",
c as char
))),
}
}
fn bad_input(msg: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, msg)
}
fn bad_data(msg: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, msg)
}
/// Les trois natures d'objet du magasin — les mêmes que git.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
pub enum Kind {
/// Contenu brut d'un fichier (sans nom : le nom vit dans le tree parent).
Blob,
/// Un répertoire : liste triée d'entrées `(mode, nom, id)`.
Tree,
/// Un instantané daté et signé d'un tree, chaîné à ses parents.
Commit,
}
impl Kind {
/// Le mot-clé exact utilisé dans l'en-tête d'objet (compatible git).
pub fn as_str(&self) -> &'static str {
match self {
Kind::Blob => "blob",
Kind::Tree => "tree",
Kind::Commit => "commit",
}
}
fn from_bytes(b: &[u8]) -> io::Result<Kind> {
match b {
b"blob" => Ok(Kind::Blob),
b"tree" => Ok(Kind::Tree),
b"commit" => Ok(Kind::Commit),
_ => Err(bad_data(format!(
"type d'objet inconnu : {:?}",
String::from_utf8_lossy(b)
))),
}
}
}
/// Une entrée de tree : `(mode, nom, id)` — un fichier ou un sous-répertoire.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct TreeEntry {
/// Mode façon git : `"100644"` pour un fichier, `"40000"` pour un tree.
pub mode: String,
/// Nom *local* (sans `/`) — le chemin complet émerge de la récursion.
pub name: String,
/// L'objet pointé (blob ou tree).
pub id: Id,
}
/// Un commit relu depuis le magasin — l'instantané et sa place dans le graphe.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Commit {
/// L'identifiant du commit lui-même.
pub id: Id,
/// Le tree racine : l'état complet du monde à cet instant.
pub tree: Id,
/// Zéro parent = racine ; un = pas ordinaire ; deux ou plus = merge.
pub parents: Vec<Id>,
/// Auteur, chaîne libre (le xerboxion signe en RS, pas en nom de personne).
pub author: String,
/// Secondes depuis l'epoch Unix (fuseau figé `+0000`).
pub timestamp: u64,
/// Le message, restitué verbatim.
pub message: String,
}
/// Un dépôt bion-git : un répertoire de travail et son magasin `.bgit/`.
///
/// Layout sur disque (calqué sur git) :
///
/// ```text
/// .bgit/
/// HEAD → "ref: refs/heads/main\n"
/// objects/ab/cdef… → objets bruts (2 chars / 38 chars), NON compressés
/// refs/heads/<branche> → 40 hex + '\n'
/// ```
pub struct Repo {
bgit: PathBuf,
}
impl Repo {
/// Crée (ou réutilise) le magasin `.bgit/` dans `dir` et ouvre le dépôt.
///
/// Idempotent : ré-initialiser un dépôt existant ne détruit rien
/// (même contrat que `git init`).
pub fn init(dir: &Path) -> io::Result<Repo> {
let bgit = dir.join(".bgit");
fs::create_dir_all(bgit.join("objects"))?;
fs::create_dir_all(bgit.join("refs/heads"))?;
let head = bgit.join("HEAD");
if !head.exists() {
fs::write(&head, "ref: refs/heads/main\n")?;
}
Ok(Repo { bgit })
}
/// Ouvre un dépôt déjà initialisé ; `NotFound` si `dir/.bgit` n'existe pas.
pub fn open(dir: &Path) -> io::Result<Repo> {
let bgit = dir.join(".bgit");
if !bgit.join("objects").is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("pas de dépôt bion-git dans {}", dir.display()),
));
}
Ok(Repo { bgit })
}
/// Chemin de l'objet `id` : `objects/ab/cdef…` (2 caractères de
/// répertoire, 38 de fichier — pour ne pas entasser des millions de
/// fichiers dans un seul dossier).
fn object_path(&self, id: &Id) -> PathBuf {
let hex = id.to_hex();
self.bgit.join("objects").join(&hex[..2]).join(&hex[2..])
}
/// Hache `data` comme un objet de type `kind`, l'écrit dans le magasin
/// et rend son [`Id`] — l'équivalent de `git hash-object -w`.
///
/// Le hash porte sur `"{kind} {len}\0" + data` : c'est l'en-tête qui
/// fait qu'un blob vide et un tree vide ont des ids différents.
/// Écriture atomique (fichier temporaire puis `rename`) et idempotente :
/// si l'objet existe déjà, il est déjà correct par construction.
pub fn hash_object(&self, data: &[u8], kind: Kind) -> io::Result<Id> {
let mut obj = Vec::with_capacity(data.len() + 16);
obj.extend_from_slice(kind.as_str().as_bytes());
obj.push(b' ');
obj.extend_from_slice(data.len().to_string().as_bytes());
obj.push(0);
obj.extend_from_slice(data);
let id = Id(sha1::sha1(&obj));
let path = self.object_path(&id);
if !path.exists() {
fs::create_dir_all(path.parent().unwrap())?;
// Atomicité : jamais d'objet à moitié écrit visible sous son nom final.
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
fs::write(&tmp, &obj)?;
fs::rename(&tmp, &path)?;
}
Ok(id)
}
/// Relit un objet : rend `(type, contenu)` — l'équivalent de
/// `git cat-file`.
///
/// Vérifie l'intégrité : le SHA-1 des octets lus doit redonner `id`
/// (un magasin content-addressed est *auto-vérifiant* — c'est le fsck
/// gratuit). `InvalidData` si l'objet est corrompu ou malformé.
pub fn cat_object(&self, id: &Id) -> io::Result<(Kind, Vec<u8>)> {
let obj = fs::read(self.object_path(id))?;
if Id(sha1::sha1(&obj)) != *id {
return Err(bad_data(format!(
"objet {id} corrompu : le hash ne correspond plus"
)));
}
let nul = obj
.iter()
.position(|&b| b == 0)
.ok_or_else(|| bad_data(format!("objet {id} : en-tête sans NUL")))?;
let header = &obj[..nul];
let space = header
.iter()
.position(|&b| b == b' ')
.ok_or_else(|| bad_data(format!("objet {id} : en-tête sans espace")))?;
let kind = Kind::from_bytes(&header[..space])?;
let len: usize = std::str::from_utf8(&header[space + 1..])
.ok()
.and_then(|s| s.parse().ok())
.ok_or_else(|| bad_data(format!("objet {id} : taille illisible")))?;
let data = obj[nul + 1..].to_vec();
if data.len() != len {
return Err(bad_data(format!(
"objet {id} : taille annoncée {len}, réelle {}",
data.len()
)));
}
Ok((kind, data))
}
// ------------------------------------------------------------------
// Trees : photographier et rematérialiser un répertoire
// ------------------------------------------------------------------
/// Photographie récursivement le répertoire `dir_snapshot` : chaque
/// fichier devient un blob, chaque répertoire un tree, et l'[`Id`] du
/// tree racine est rendu — l'équivalent de `git write-tree`, sans index.
///
/// Règles (celles de git) :
/// - `.bgit` est ignoré (le magasin ne se photographie pas lui-même) ;
/// - les répertoires vides ne produisent pas d'entrée (git ne suit pas
/// les dossiers vides) — sauf la racine, qui peut être le tree vide ;
/// - les entrées sont triées par octets du nom, les répertoires comptant
/// comme `nom + "/"` (la subtilité de tri qui garantit qu'un même
/// contenu donne toujours le même id, quel que soit l'ordre du FS) ;
/// - noms non-UTF-8 et types exotiques (symlinks…) → refus explicite.
pub fn write_tree(&self, dir_snapshot: &Path) -> io::Result<Id> {
self.write_tree_inner(dir_snapshot)
}
fn write_tree_inner(&self, dir: &Path) -> io::Result<Id> {
// (clé de tri, octets d'entrée) — la clé traite un répertoire comme "nom/".
let mut entries: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for dirent in fs::read_dir(dir)? {
let dirent = dirent?;
let name = dirent
.file_name()
.into_string()
.map_err(|n| bad_data(format!("nom non-UTF-8 : {:?}", n)))?;
if name == ".bgit" {
continue;
}
let ftype = dirent.file_type()?;
let (mode, id, is_dir) = if ftype.is_dir() {
let sub = self.write_tree_inner(&dirent.path())?;
// Sous-répertoire vide → pas d'entrée (comme git).
let (_, data) = self.cat_object(&sub)?;
if data.is_empty() {
continue;
}
("40000", sub, true)
} else if ftype.is_file() {
let blob = self.hash_object(&fs::read(dirent.path())?, Kind::Blob)?;
("100644", blob, false)
} else {
return Err(bad_data(format!(
"type de fichier non géré (symlink ?) : {}",
dirent.path().display()
)));
};
let mut key = name.as_bytes().to_vec();
if is_dir {
key.push(b'/');
}
let mut raw = Vec::with_capacity(name.len() + 28);
raw.extend_from_slice(mode.as_bytes());
raw.push(b' ');
raw.extend_from_slice(name.as_bytes());
raw.push(0);
raw.extend_from_slice(&id.0);
entries.push((key, raw));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut payload = Vec::new();
for (_, raw) in entries {
payload.extend_from_slice(&raw);
}
self.hash_object(&payload, Kind::Tree)
}
/// Décode un objet tree en ses entrées `(mode, nom, id)`.
pub fn read_tree(&self, id: &Id) -> io::Result<Vec<TreeEntry>> {
let (kind, data) = self.cat_object(id)?;
if kind != Kind::Tree {
return Err(bad_data(format!(
"{id} est un {}, pas un tree",
kind.as_str()
)));
}
let mut entries = Vec::new();
let mut rest = &data[..];
while !rest.is_empty() {
let space = rest
.iter()
.position(|&b| b == b' ')
.ok_or_else(|| bad_data(format!("tree {id} : entrée sans espace")))?;
let mode = std::str::from_utf8(&rest[..space])
.map_err(|_| bad_data(format!("tree {id} : mode non-UTF-8")))?
.to_string();
rest = &rest[space + 1..];
let nul = rest
.iter()
.position(|&b| b == 0)
.ok_or_else(|| bad_data(format!("tree {id} : entrée sans NUL")))?;
let name = std::str::from_utf8(&rest[..nul])
.map_err(|_| bad_data(format!("tree {id} : nom non-UTF-8")))?
.to_string();
rest = &rest[nul + 1..];
if rest.len() < 20 {
return Err(bad_data(format!("tree {id} : id tronqué")));
}
let mut oid = [0u8; 20];
oid.copy_from_slice(&rest[..20]);
rest = &rest[20..];
entries.push(TreeEntry {
mode,
name,
id: Id(oid),
});
}
Ok(entries)
}
/// Rematérialise le tree `id` dans le répertoire `dest` (créé si besoin) —
/// l'inverse de [`Repo::write_tree`], l'équivalent d'un checkout.
///
/// Sécurité : les noms d'entrée contenant `/`, `\`, `..`, `.` ou vides
/// sont refusés — un objet forgé ne peut pas écrire hors de `dest`.
pub fn checkout_tree(&self, id: &Id, dest: &Path) -> io::Result<()> {
fs::create_dir_all(dest)?;
for entry in self.read_tree(id)? {
if entry.name.is_empty()
|| entry.name == "."
|| entry.name == ".."
|| entry.name.contains('/')
|| entry.name.contains('\\')
{
return Err(bad_data(format!(
"nom d'entrée dangereux refusé : {:?}",
entry.name
)));
}
let path = dest.join(&entry.name);
match entry.mode.as_str() {
"40000" | "040000" => self.checkout_tree(&entry.id, &path)?,
_ => {
let (kind, data) = self.cat_object(&entry.id)?;
if kind != Kind::Blob {
return Err(bad_data(format!("{} n'est pas un blob", entry.id)));
}
fs::write(&path, data)?;
}
}
}
Ok(())
}
// ------------------------------------------------------------------
// Commits : chaîner les instantanés
// ------------------------------------------------------------------
/// Crée un commit daté de *maintenant*. Voir [`Repo::commit_at`].
pub fn commit(&self, tree: Id, parents: &[Id], msg: &str, author: &str) -> io::Result<Id> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| bad_data(format!("horloge avant l'epoch : {e}")))?
.as_secs();
self.commit_at(tree, parents, msg, author, now)
}
/// Crée un commit avec un horodatage explicite (secondes Unix) —
/// la variante **déterministe** : mêmes entrées → même [`Id`], toujours.
/// (Rejouabilité : la même vertu que les tsoins.)
///
/// Format textuel, calqué sur git :
///
/// ```text
/// tree <40 hex>
/// parent <40 hex> (une ligne par parent, dans l'ordre donné)
/// author <chaîne libre> <ts> +0000
/// committer <chaîne libre> <ts> +0000
///
/// <message verbatim>
/// ```
///
/// `author` ne doit pas contenir de saut de ligne (`InvalidInput` sinon) ;
/// le message, lui, peut être multi-lignes — il est restitué tel quel.
pub fn commit_at(
&self,
tree: Id,
parents: &[Id],
msg: &str,
author: &str,
timestamp: u64,
) -> io::Result<Id> {
if author.contains('\n') {
return Err(bad_input(
"l'auteur ne peut pas contenir de saut de ligne".into(),
));
}
let (kind, _) = self.cat_object(&tree)?;
if kind != Kind::Tree {
return Err(bad_input(format!(
"{tree} est un {}, pas un tree",
kind.as_str()
)));
}
let mut text = String::new();
text.push_str(&format!("tree {tree}\n"));
for p in parents {
text.push_str(&format!("parent {p}\n"));
}
text.push_str(&format!("author {author} {timestamp} +0000\n"));
text.push_str(&format!("committer {author} {timestamp} +0000\n"));
text.push('\n');
text.push_str(msg);
self.hash_object(text.as_bytes(), Kind::Commit)
}
/// Relit et décode un commit du magasin.
pub fn read_commit(&self, id: &Id) -> io::Result<Commit> {
let (kind, data) = self.cat_object(id)?;
if kind != Kind::Commit {
return Err(bad_data(format!(
"{id} est un {}, pas un commit",
kind.as_str()
)));
}
let text =
String::from_utf8(data).map_err(|_| bad_data(format!("commit {id} : non-UTF-8")))?;
let (header, message) = text
.split_once("\n\n")
.ok_or_else(|| bad_data(format!("commit {id} : pas de ligne vide avant le message")))?;
let mut tree = None;
let mut parents = Vec::new();
let mut author = None;
let mut timestamp = None;
for line in header.lines() {
if let Some(v) = line.strip_prefix("tree ") {
tree = Some(Id::from_hex(v)?);
} else if let Some(v) = line.strip_prefix("parent ") {
parents.push(Id::from_hex(v)?);
} else if let Some(v) = line.strip_prefix("author ") {
// "author <chaîne libre> <ts> +0000" → on détache les 2 derniers mots.
let no_tz = v
.rsplit_once(' ')
.ok_or_else(|| bad_data(format!("commit {id} : ligne author malformée")))?
.0;
let (name, ts) = no_tz
.rsplit_once(' ')
.ok_or_else(|| bad_data(format!("commit {id} : ligne author malformée")))?;
author = Some(name.to_string());
timestamp = Some(
ts.parse::<u64>()
.map_err(|_| bad_data(format!("commit {id} : timestamp illisible")))?,
);
}
// "committer" : redondant avec author dans ce bion, ignoré à la lecture.
}
Ok(Commit {
id: *id,
tree: tree.ok_or_else(|| bad_data(format!("commit {id} : pas de tree")))?,
parents,
author: author.ok_or_else(|| bad_data(format!("commit {id} : pas d'author")))?,
timestamp: timestamp.unwrap_or(0),
message: message.to_string(),
})
}
/// Remonte l'histoire depuis `from` en suivant le **premier parent** de
/// chaque commit, et rend la liste du plus récent au plus ancien —
/// l'équivalent de `git log --first-parent`.
///
/// (Suivre le premier parent suffit à raconter *une* ligne d'histoire ;
/// explorer tout le graphe d'un merge est laissé à l'appelant, qui a
/// `parents` sous la main.)
pub fn log(&self, from: Id) -> io::Result<Vec<Commit>> {
let mut out = Vec::new();
let mut cursor = Some(from);
let mut vus = std::collections::HashSet::new();
while let Some(id) = cursor {
if !vus.insert(id) {
return Err(bad_data(format!(
"cycle dans l'histoire à {id} (magasin corrompu)"
)));
}
let c = self.read_commit(&id)?;
cursor = c.parents.first().copied();
out.push(c);
}
Ok(out)
}
// ------------------------------------------------------------------
// Branches : des noms posés sur des commits
// ------------------------------------------------------------------
/// Pose (ou déplace) la branche `name` sur le commit `at`.
///
/// Une branche n'est *que ça* : 41 octets dans `refs/heads/<name>`.
/// C'est pourquoi forker est gratuit. Le nom doit rester simple
/// (alphanumérique, `-`, `_`, `.`) — pas de `/` ni de traversée.
pub fn branch(&self, name: &str, at: Id) -> io::Result<()> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|| name.starts_with('.')
{
return Err(bad_input(format!("nom de branche invalide : {name:?}")));
}
fs::write(self.bgit.join("refs/heads").join(name), format!("{at}\n"))
}
/// Lit l'[`Id`] pointé par la branche `name` ; `NotFound` si elle n'existe pas.
pub fn branch_target(&self, name: &str) -> io::Result<Id> {
let s = fs::read_to_string(self.bgit.join("refs/heads").join(name))?;
Id::from_hex(&s)
}
/// Liste les branches existantes, triées par nom.
pub fn branches(&self) -> io::Result<Vec<String>> {
let mut out: Vec<String> = fs::read_dir(self.bgit.join("refs/heads"))?
.filter_map(|e| e.ok()?.file_name().into_string().ok())
.collect();
out.sort();
Ok(out)
}
}
// ----------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static COMPTEUR: AtomicUsize = AtomicUsize::new(0);
/// Un répertoire temporaire unique par test (std-only, pas de crate tempfile).
fn dir_temporaire() -> PathBuf {
let n = COMPTEUR.fetch_add(1, Ordering::SeqCst);
let d = std::env::temp_dir().join(format!("bion-git-test-{}-{}", std::process::id(), n));
fs::create_dir_all(&d).unwrap();
d
}
struct Nettoyeur(PathBuf);
impl Drop for Nettoyeur {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn repo_test() -> (Repo, PathBuf, Nettoyeur) {
let d = dir_temporaire();
let repo = Repo::init(&d).unwrap();
let gardien = Nettoyeur(d.clone());
(repo, d, gardien)
}
/// Nos identifiants de blobs et de trees sont EXACTEMENT ceux de git —
/// vecteurs bien connus (`git hash-object`, tree vide).
#[test]
fn ids_identiques_a_git() {
let (repo, _d, _g) = repo_test();
// blob vide
assert_eq!(
repo.hash_object(b"", Kind::Blob).unwrap().to_hex(),
"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
);
// `echo "hello world" | git hash-object --stdin`
assert_eq!(
repo.hash_object(b"hello world\n", Kind::Blob)
.unwrap()
.to_hex(),
"3b18e512dba79e4c8300dd08aeb37f8e728b8dad"
);
// le tree vide, le plus célèbre des ids de git
assert_eq!(
repo.hash_object(b"", Kind::Tree).unwrap().to_hex(),
"4b825dc642cb6eb9a060e54bf8d69288fbee4904"
);
}
/// Le layout disque est bien objects/ab/cdef… (2 / 38).
#[test]
fn layout_objets_2_38() {
let (repo, d, _g) = repo_test();
let id = repo.hash_object(b"xerboxion", Kind::Blob).unwrap();
let hex = id.to_hex();
let chemin = d.join(".bgit/objects").join(&hex[..2]).join(&hex[2..]);
assert!(chemin.is_file(), "objet absent de {}", chemin.display());
assert_eq!(hex[..2].len(), 2);
assert_eq!(hex[2..].len(), 38);
}
/// Aller-retour blob : ce qu'on écrit est ce qu'on relit, type compris.
/// Et l'écriture est idempotente (déduplication par construction).
#[test]
fn roundtrip_blob() {
let (repo, _d, _g) = repo_test();
let data = b"contenu \x00 binaire \xff aussi".to_vec();
let id1 = repo.hash_object(&data, Kind::Blob).unwrap();
let id2 = repo.hash_object(&data, Kind::Blob).unwrap();
assert_eq!(id1, id2, "même contenu → même id (dédup)");
let (kind, relu) = repo.cat_object(&id1).unwrap();
assert_eq!(kind, Kind::Blob);
assert_eq!(relu, data);
}
/// Le magasin est auto-vérifiant : un objet corrompu sur disque est
/// détecté à la lecture (le hash ne correspond plus).
#[test]
fn corruption_detectee() {
let (repo, d, _g) = repo_test();
let id = repo.hash_object(b"important", Kind::Blob).unwrap();
let hex = id.to_hex();
let chemin = d.join(".bgit/objects").join(&hex[..2]).join(&hex[2..]);
fs::write(&chemin, b"blob 7\0sabotee").unwrap();
let err = repo.cat_object(&id).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
/// Photographier un répertoire imbriqué puis le rematérialiser ailleurs :
/// contenu identique, et le même contenu redonne le même id de tree
/// (déterminisme, quel que soit l'ordre de création des fichiers).
#[test]
fn roundtrip_tree_imbrique() {
let (repo, d, _g) = repo_test();
let src = d.join("monde");
fs::create_dir_all(src.join("src/coeur")).unwrap();
fs::write(src.join("lisez-moi.txt"), "un bion = un cours\n").unwrap();
fs::write(src.join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(src.join("src/coeur/bion.rs"), "// cœur\n").unwrap();
fs::create_dir_all(src.join("vide")).unwrap(); // ignoré, comme git
let tree = repo.write_tree(&src).unwrap();
// Vecteur vérifié contre le VRAI git (`git write-tree` sur le même
// contenu) : nos trees sont bit-à-bit compatibles, tri compris.
assert_eq!(tree.to_hex(), "15c93b9663e95c58becfa3172ed753e14dbef718");
// Même contenu recréé dans un AUTRE ordre → même id.
let src2 = d.join("monde-bis");
fs::create_dir_all(src2.join("src/coeur")).unwrap();
fs::write(src2.join("src/coeur/bion.rs"), "// cœur\n").unwrap();
fs::write(src2.join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(src2.join("lisez-moi.txt"), "un bion = un cours\n").unwrap();
assert_eq!(
repo.write_tree(&src2).unwrap(),
tree,
"content-addressing : même monde, même nom"
);
let dest = d.join("restaure");
repo.checkout_tree(&tree, &dest).unwrap();
assert_eq!(
fs::read_to_string(dest.join("lisez-moi.txt")).unwrap(),
"un bion = un cours\n"
);
assert_eq!(
fs::read_to_string(dest.join("src/main.rs")).unwrap(),
"fn main() {}\n"
);
assert_eq!(
fs::read_to_string(dest.join("src/coeur/bion.rs")).unwrap(),
"// cœur\n"
);
assert!(
!dest.join("vide").exists(),
"les répertoires vides ne sont pas suivis"
);
assert!(
!dest.join(".bgit").exists(),
"le magasin ne se photographie pas lui-même"
);
}
/// Aller-retour commit : tous les champs se relisent exactement,
/// message multi-lignes compris. Et commit_at est déterministe.
#[test]
fn roundtrip_commit() {
let (repo, d, _g) = repo_test();
fs::write(d.join("a.txt"), "a").unwrap();
let tree = repo.write_tree(&d).unwrap();
let msg = "premier instant\n\navec un corps\nmulti-lignes";
let id = repo
.commit_at(tree, &[], msg, "rs-1 <rs1@xerboxion>", 1_755_300_000)
.unwrap();
let id_bis = repo
.commit_at(tree, &[], msg, "rs-1 <rs1@xerboxion>", 1_755_300_000)
.unwrap();
assert_eq!(id, id_bis, "commit_at est déterministe");
let c = repo.read_commit(&id).unwrap();
assert_eq!(c.id, id);
assert_eq!(c.tree, tree);
assert!(c.parents.is_empty());
assert_eq!(c.author, "rs-1 <rs1@xerboxion>");
assert_eq!(c.timestamp, 1_755_300_000);
assert_eq!(c.message, msg);
}
/// log d'une chaîne de trois commits : du plus récent au plus ancien.
#[test]
fn log_chaine() {
let (repo, d, _g) = repo_test();
fs::write(d.join("a.txt"), "v1").unwrap();
let t1 = repo.write_tree(&d).unwrap();
let c1 = repo.commit_at(t1, &[], "un", "rs-1", 100).unwrap();
fs::write(d.join("a.txt"), "v2").unwrap();
let t2 = repo.write_tree(&d).unwrap();
let c2 = repo.commit_at(t2, &[c1], "deux", "rs-1", 200).unwrap();
fs::write(d.join("a.txt"), "v3").unwrap();
let t3 = repo.write_tree(&d).unwrap();
let c3 = repo.commit_at(t3, &[c2], "trois", "rs-1", 300).unwrap();
let histoire = repo.log(c3).unwrap();
assert_eq!(histoire.len(), 3);
assert_eq!(
histoire
.iter()
.map(|c| c.message.as_str())
.collect::<Vec<_>>(),
["trois", "deux", "un"]
);
assert_eq!(histoire[2].parents, Vec::<Id>::new());
}
/// LE test du principe : deux branches qui divergent depuis un parent
/// commun — le fork VISIBLE. Les deux histoires partagent leur racine
/// sans copier un octet, et chaque branche se relit par son nom.
#[test]
fn fork_deux_branches_divergentes() {
let (repo, d, _g) = repo_test();
fs::write(d.join("monde.txt"), "commun").unwrap();
let racine_tree = repo.write_tree(&d).unwrap();
let racine = repo
.commit_at(racine_tree, &[], "racine", "rs-1", 100)
.unwrap();
// Branche main : le chemin d'origine continue.
fs::write(d.join("monde.txt"), "chemin main").unwrap();
let tm = repo.write_tree(&d).unwrap();
let cm = repo
.commit_at(tm, &[racine], "suite sur main", "rs-1", 200)
.unwrap();
repo.branch("main", cm).unwrap();
// Branche fork : la bifurcation, depuis LE MÊME parent.
fs::write(d.join("monde.txt"), "chemin fork").unwrap();
let tf = repo.write_tree(&d).unwrap();
let cf = repo
.commit_at(tf, &[racine], "bifurcation", "rs-7", 200)
.unwrap();
repo.branch("fork", cf).unwrap();
assert_ne!(
cm, cf,
"deux contenus, deux ids : la divergence est visible"
);
assert_eq!(repo.branch_target("main").unwrap(), cm);
assert_eq!(repo.branch_target("fork").unwrap(), cf);
assert_eq!(repo.branches().unwrap(), ["fork", "main"]);
// Les deux histoires convergent (en remontant) vers la même racine.
let log_main = repo.log(repo.branch_target("main").unwrap()).unwrap();
let log_fork = repo.log(repo.branch_target("fork").unwrap()).unwrap();
assert_eq!(log_main.last().unwrap().id, racine);
assert_eq!(log_fork.last().unwrap().id, racine);
// …et le passé commun n'est stocké qu'une fois : même objet racine.
assert_eq!(log_main.last().unwrap(), log_fork.last().unwrap());
}
/// Un merge est représentable : un commit à deux parents, que log
/// remonte par son premier parent.
#[test]
fn merge_deux_parents() {
let (repo, d, _g) = repo_test();
fs::write(d.join("x"), "0").unwrap();
let t0 = repo.write_tree(&d).unwrap();
let racine = repo.commit_at(t0, &[], "racine", "rs-1", 1).unwrap();
let a = repo.commit_at(t0, &[racine], "a", "rs-1", 2).unwrap();
let b = repo.commit_at(t0, &[racine], "b", "rs-7", 2).unwrap();
let m = repo
.commit_at(t0, &[a, b], "retrouvailles", "rs-1", 3)
.unwrap();
let c = repo.read_commit(&m).unwrap();
assert_eq!(c.parents, vec![a, b]);
let histoire = repo.log(m).unwrap();
assert_eq!(
histoire
.iter()
.map(|c| c.message.as_str())
.collect::<Vec<_>>(),
["retrouvailles", "a", "racine"]
);
}
/// Cas limites d'Id : parsing hex strict, aller-retour Display/FromStr.
#[test]
fn id_hex_cas_limites() {
assert!(Id::from_hex("abc").is_err(), "trop court");
assert!(Id::from_hex(&"z".repeat(40)).is_err(), "non-hex");
let id = Id::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap();
assert_eq!(id.to_string().parse::<Id>().unwrap(), id);
// Les majuscules sont acceptées en entrée, normalisées en sortie.
let maj = Id::from_hex("4B825DC642CB6EB9A060E54BF8D69288FBEE4904").unwrap();
assert_eq!(maj, id);
}
/// Erreurs propres : objet inconnu, mauvais type au commit,
/// branche au nom dangereux, dépôt inexistant.
#[test]
fn erreurs_propres() {
let (repo, d, _g) = repo_test();
let fantome = Id([0u8; 20]);
assert_eq!(
repo.cat_object(&fantome).unwrap_err().kind(),
io::ErrorKind::NotFound
);
assert!(repo.checkout_tree(&fantome, &d.join("nulle-part")).is_err());
// Un commit doit pointer un tree, pas un blob.
let blob = repo.hash_object(b"x", Kind::Blob).unwrap();
assert!(repo.commit(blob, &[], "m", "rs-1").is_err());
// Pas de traversée par le nom de branche.
let tree = repo.hash_object(b"", Kind::Tree).unwrap();
let c = repo.commit_at(tree, &[], "m", "rs-1", 1).unwrap();
assert!(repo.branch("../evasion", c).is_err());
assert!(repo.branch("a/b", c).is_err());
assert!(repo.branch("", c).is_err());
assert!(repo.branch_target("inconnue").is_err());
// open() exige un dépôt initialisé.
assert!(Repo::open(&d.join("pas-un-depot")).is_err());
}
/// L'auteur ne peut pas casser le format textuel du commit
/// (injection de ligne « parent » via un \n).
#[test]
fn auteur_sans_injection() {
let (repo, _d, _g) = repo_test();
let tree = repo.hash_object(b"", Kind::Tree).unwrap();
let e = repo
.commit_at(tree, &[], "m", "rs-1\nparent 0000", 1)
.unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::InvalidInput);
}
}