//! # 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 { 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::from_hex(s) } } fn hex_val(c: u8) -> io::Result { 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 { 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, /// 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/ → 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 { 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 { 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 { 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)> { 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 { self.write_tree_inner(dir_snapshot) } fn write_tree_inner(&self, dir: &Path) -> io::Result { // (clé de tri, octets d'entrée) — la clé traite un répertoire comme "nom/". let mut entries: Vec<(Vec, Vec)> = 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> { 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 { 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 +0000 /// committer +0000 /// /// /// ``` /// /// `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 { 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 { 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 +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::() .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> { 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/`. /// 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 { 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> { let mut out: Vec = 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 ", 1_755_300_000) .unwrap(); let id_bis = repo .commit_at(tree, &[], msg, "rs-1 ", 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 "); 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::>(), ["trois", "deux", "un"] ); assert_eq!(histoire[2].parents, Vec::::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::>(), ["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::().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); } }