🦀 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>
This commit is contained in:
986
bion-git/src/lib.rs
Normal file
986
bion-git/src/lib.rs
Normal file
@@ -0,0 +1,986 @@
|
||||
//! # 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user