Tu propio Git mínimo en Rust: objetos, hashes, commits y log
Inicia sesión para descargarAprende a construir un MiniGit educativo en Rust desde cero. En este tutorial crearás una herramienta de consola capaz de inicializar un repositorio .minigit, calcular hashes tipo Git, guardar objetos blob, recuperar …
Contenido del tutorial ⌄
- 1. Qué vamos a construir
- 2. Alcance del tutorial: qué no vamos a construir todavía
- 3. El problema inicial: guardar versiones a mano
- 4. Modelo mental: objetos y blobs
- 5. Hashes y bytes exactos
- 6. Por qué usamos SHA-1
- 7. Crear el proyecto Rust
- 8. Configurar Cargo.toml
- 10. Crear main.rs
- 11. Crear commands.rs
- 12. Crear repository.rs
- 12.1 La función init_repo()
- 12.2 Validar que el repositorio existe
- 12.3 Construir rutas para objetos
- 12.4 La ruta de refs/heads/main
- 12.5 Leer el último commit
- 12.6 Actualizar la referencia principal
- 13. Crear objects.rs
- 13.1 Calcular el hash de un archivo
- 13.2 Guardar un objeto blob
- 13.3 Mostrar un objeto guardado
- 13.4 Construir un blob
- 13.5 Construir un objeto genérico
- 13.6 Leer un objeto guardado
- 13.7 Calcular SHA-1 como texto hexadecimal
- 13.8 Validar hashes
- 14. Probar init
- 15. Probar hash y comparar con Git
- 16. Probar write y show
🦀 Rust · Git desde cero · Objetos · Hashes · Commits
En este tutorial vamos a construir un MiniGit educativo usando Rust. No será una copia completa de Git, pero sí una versión pequeña y verificable para entender sus ideas internas: objetos, hashes, almacenamiento, commits, referencias y recorrido del historial.
Git es una herramienta que usamos casi todos los días, pero muchas veces la usamos como
una caja negra. Sabemos hacer init, commit y log,
pero no siempre vemos qué ocurre por dentro cuando el contenido empieza a convertirse
en historial.
La idea de este tutorial es mirar debajo de esa capa. Vamos a construir un proyecto
pequeño, llamado minigit, para ver cómo un archivo puede transformarse en
un objeto, cómo ese objeto recibe una identidad mediante un hash y cómo varios commits
pueden conectarse para formar una historia.
Lo importante no será memorizar comandos. Lo importante será entender el modelo: contenido guardado como objetos, objetos identificados por hash y commits conectados por referencias.
Git no empieza siendo ramas, merges o conflictos. Antes de todo eso, Git empieza con una idea más simple y poderosa: guardar contenido como objetos identificados por hash.
Al final tendrás una herramienta de consola escrita en Rust que crea su propia carpeta interna, guarda objetos, recupera contenido, crea commits mínimos y muestra un historial desde la terminal.
Objetivo práctico del tutorial
Este tutorial está completo cuando puedas abrir la carpeta .minigit y
entender qué representa cada archivo interno: dónde viven los objetos, cómo se
identifica el contenido y cómo una referencia apunta al último commit.
La regla de trabajo será sencilla: cada idea importante debe poder comprobarse. Si decimos que un objeto se guardó, veremos dónde quedó. Si decimos que un commit apunta a otro, inspeccionaremos el dato que guarda esa relación.
Así, MiniGit no será solo un programa que compila. Será una forma de abrir la caja negra y mirar, paso a paso, cómo una carpeta puede empezar a convertirse en historia.
1. Qué vamos a construir
Antes de tocar el código, vamos a mirar el resultado completo que buscamos. La idea no es crear una copia de Git real, sino una herramienta pequeña que nos permita ver sus piezas esenciales funcionando con archivos reales.
Nuestro programa se llamará minigit. Desde la terminal podremos crear una
carpeta interna llamada .minigit, calcular hashes tipo Git, guardar objetos,
recuperar contenido, crear commits mínimos y leer el historial.
Cada comando tendrá una consecuencia visible. No vamos a quedarnos en teoría: después de ejecutar cada paso, podremos abrir archivos internos y comprobar qué cambió dentro del repositorio.
cargo run -- init
cargo run -- hash nota.txt
cargo run -- write nota.txt
cargo run -- show <hash>
cargo run -- commit nota.txt -m "primer commit"
cargo run -- log
El comando init será el punto de partida. Creará la estructura interna de
MiniGit, parecida en intención a una carpeta .git, pero mucho más pequeña
y fácil de inspeccionar.
El comando hash calculará la identidad de un archivo usando un formato
compatible con la idea de los objetos tipo blob. Esta parte nos servirá
para entender que un hash depende de bytes exactos, no solo de lo que el texto parece
decir a simple vista.
El comando write guardará el objeto dentro de .minigit/objects.
En vez de guardar el archivo con su nombre original, MiniGit lo guardará usando el hash
como identificador.
El comando show hará el camino contrario: recibirá un hash, buscará el
objeto guardado y mostrará nuevamente el contenido original.
Finalmente, commit y log nos permitirán pasar de objetos
sueltos a historia. Un commit guardará una referencia al contenido y el log recorrerá
esa cadena para mostrar el historial.
Resultado final de esta primera versión
Al terminar este tutorial, MiniGit podrá inicializar su propia carpeta interna, guardar objetos identificados por hash, recuperar contenido desde esos hashes, crear commits mínimos y mostrar el historial en la terminal.
Lo importante será que cada resultado se pueda verificar mirando la carpeta
.minigit. Si algo se guarda, veremos dónde quedó. Si una referencia
apunta al último commit, podremos abrir el archivo que contiene ese hash.
archivo
↓
bytes exactos
↓
objeto blob
↓
SHA-1
↓
.minigit/objects/<hash>
↓
commit
↓
.minigit/refs/heads/main
↓
log
Ese flujo será el mapa mental del proyecto. Primero entenderemos cómo un archivo se
lee como bytes. Luego veremos cómo esos bytes se envuelven en un objeto tipo
blob. Después calcularemos un hash, guardaremos el objeto y lo podremos
recuperar.
Cuando esa primera parte funcione, agregaremos commits. Un commit será otro objeto guardado por hash, pero con una diferencia importante: además de apuntar al contenido, también podrá apuntar al commit anterior.
Así MiniGit empieza a parecerse a una herramienta de control de versiones: no solo guarda contenido, sino que empieza a construir una historia.
2. Alcance del tutorial: qué no vamos a construir todavía
Antes de avanzar, necesitamos poner una frontera clara. Git real es una herramienta enorme. Tiene ramas, merges, árboles de directorios, compresión, optimizaciones internas, objetos empaquetados, áreas de preparación, etiquetas, operaciones remotas y muchas reglas que existen para resolver casos complejos.
En este tutorial no vamos a copiar todo eso. Vamos a construir una versión pequeña, didáctica y verificable. La idea es entender el corazón del modelo: cómo el contenido se convierte en objetos, cómo esos objetos reciben hashes, cómo se guardan y cómo los commits empiezan a formar una historia.
Esta decisión es importante porque nos permite aprender sin perdernos. Si intentamos construir todo Git desde el primer artículo, el tutorial se vuelve pesado antes de que aparezca la idea más valiosa. En cambio, si empezamos con una versión mínima, podemos ver cada pieza funcionando y después mejorarla.
MiniGit será una herramienta incompleta a propósito. Esa incompletitud no es un error: es una estrategia pedagógica. Primero construiremos lo esencial. Después podremos agregar más piezas, como árboles, múltiples archivos y recuperación de versiones anteriores.
No vamos a construir todavía:
- ramas reales
- merge
- diff
- checkout completo
- staging avanzado
- tags
- repositorios remotos
- packfiles
- compresión de objetos
- resolución de conflictos
- árboles de directorios completos
También haremos una simplificación técnica importante: en Git real, un commit no apunta
directamente a un archivo. Un commit apunta a un tree, y ese
tree representa el estado de una carpeta con uno o varios archivos.
En esta primera versión, para mantener el proyecto corto y comprensible, nuestro commit
apuntará directamente a un objeto tipo blob. Eso nos permite concentrarnos
en la cadena principal de ideas: archivo, objeto, hash, commit y log.
Más adelante, cuando esta base esté clara, podremos dar el siguiente paso natural:
reemplazar el modelo simple de commit → blob por un modelo más parecido
al de Git real: commit → tree → blobs.
MiniGit en este tutorial:
commit
↓
blob
Git real:
commit
↓
tree
↓
blobs
Criterio de esta primera versión
Si al final del tutorial entiendes cómo MiniGit guarda objetos, calcula hashes, crea commits mínimos y recorre el historial, esta versión habrá cumplido su objetivo. Todo lo demás vendrá después, sobre una base que ya podemos inspeccionar.
3. El problema inicial: guardar versiones a mano
Antes de hablar de objetos, hashes o commits, pensemos en el problema que Git intenta resolver. Cuando un proyecto empieza pequeño, guardar versiones parece fácil. Hacemos una copia de la carpeta, cambiamos el nombre y seguimos trabajando.
El problema aparece cuando esa costumbre se repite muchas veces. De pronto tenemos varias carpetas parecidas, nombres confusos y ninguna forma clara de saber qué cambió, cuándo cambió o cuál versión representa realmente el estado correcto del proyecto.
Algo así puede pasarle a cualquiera:
mi_proyecto/
mi_proyecto_v1/
mi_proyecto_v2/
mi_proyecto_final/
mi_proyecto_final_final/
mi_proyecto_final_final_ahora_si/
Esta forma de trabajar parece simple, pero se rompe rápido. Si quieres saber qué cambió
entre mi_proyecto_v2 y mi_proyecto_final, tienes que comparar
archivos manualmente. Si quieres volver a una versión anterior, necesitas recordar cuál
carpeta era la buena. Si varias personas trabajan sobre el mismo proyecto, el desorden
crece todavía más.
Además, copiar carpetas completas desperdicia espacio. Si solo cambiaste una línea en un archivo, no tiene mucho sentido duplicar todo el proyecto como si todo hubiera cambiado.
Git resuelve este problema de otra manera. En lugar de pensar en carpetas duplicadas, piensa en contenido identificado. Guarda objetos, calcula hashes y conecta estados del proyecto mediante commits.
Esa es la idea que vamos a reproducir en MiniGit. No vamos a guardar carpetas completas con nombres cada vez más largos. Vamos a tomar un archivo, leer sus bytes, construir un objeto, calcular su hash y guardarlo en una carpeta interna.
Después, cuando agreguemos commits, ese contenido podrá formar parte de una historia. Pero el primer cambio mental es este: una versión no tiene que ser una copia completa de una carpeta. Puede ser una estructura de objetos conectados.
Idea clave
Git no necesita guardar carpetas duplicadas para recordar la historia de un proyecto. Su modelo parte de una idea más poderosa: guardar contenido como objetos identificados por hash y luego conectar esos objetos mediante commits.
4. Modelo mental: objetos y blobs
Ya vimos que copiar carpetas completas no es una buena forma de construir historial. Ahora necesitamos una idea más precisa: guardar contenido como objetos.
En Git, un objeto es una pieza de información almacenada de forma estructurada. No es simplemente el texto de un archivo tirado en una carpeta. Antes de calcular su hash, Git envuelve ese contenido con una cabecera que indica qué tipo de objeto es y cuántos bytes contiene.
En esta primera versión de MiniGit vamos a trabajar con el objeto más sencillo:
el blob.
Un blob representa el contenido de un archivo. No guarda el nombre del
archivo, no guarda la carpeta donde estaba y no guarda un mensaje de commit. Solo guarda
contenido.
Eso puede parecer extraño al principio. Si tenemos un archivo llamado
nota.txt, uno podría pensar que Git guarda algo como “nota.txt contiene
hola mundo”. Pero el modelo de objetos separa esas responsabilidades. El blob se ocupa
del contenido; otras estructuras se encargarán más adelante de los nombres, carpetas e
historial.
Por ahora, MiniGit hará lo mismo: leerá un archivo como bytes, construirá un objeto tipo
blob y calculará un hash sobre ese objeto completo.
blob <tamaño>\0<contenido>
Esa línea es el formato mental que necesitamos recordar. Primero aparece la palabra
blob, luego un espacio, luego el tamaño del contenido en bytes, después un
separador especial y finalmente el contenido real del archivo.
El separador \0 representa un byte nulo. No es el texto visible
\ seguido de 0. Es un byte especial que separa la cabecera del
contenido.
Por ejemplo, si el archivo contiene exactamente hola mundo, sin salto de
línea al final, el contenido tiene 10 bytes.
blob 10\0hola mundo
Lo importante es que el hash no se calcula únicamente sobre hola mundo.
Se calcula sobre el objeto completo: la cabecera, el separador y el contenido.
En otras palabras, MiniGit no va a tomar solo el texto visible del archivo. Va a construir una representación interna parecida a la de Git y luego calculará el hash sobre esa representación.
Esa decisión hace que el tipo del objeto y el tamaño del contenido también formen parte de la identidad. Un contenido no queda identificado de cualquier manera, sino dentro de una estructura clara.
Idea clave
Un blob no es solo el contenido visible de un archivo. Es un objeto con
una cabecera, un separador y bytes de contenido. El hash se calcula sobre todo ese
objeto, no solamente sobre el texto que vemos en pantalla.
Esta idea será la base del archivo objects.rs. Más adelante escribiremos
una función que construye exactamente este formato: recibe bytes, arma la cabecera,
agrega el separador y devuelve el objeto completo listo para ser hasheado o guardado.
Antes de escribir esa función, todavía necesitamos aclarar un detalle importante: los hashes dependen de bytes exactos. Un salto de línea invisible puede cambiar por completo el resultado.
5. Hashes y bytes exactos
Ahora que entendemos el formato de un objeto tipo blob, necesitamos hablar
de un detalle que parece pequeño, pero cambia todo: los hashes dependen de bytes exactos.
Un hash no identifica lo que creemos que dice un archivo. Identifica los bytes reales que hay dentro del archivo. Eso significa que dos archivos visualmente parecidos pueden producir hashes diferentes si sus bytes no son exactamente iguales.
Por eso, cuando probemos nuestro MiniGit, debemos tener cuidado con los saltos de línea, los espacios invisibles y la forma en que creamos los archivos desde la terminal.
El caso más común ocurre con echo. Muchas veces, cuando usamos
echo para crear un archivo, la terminal agrega un salto de línea al final.
Ese salto de línea también es un byte, aunque no siempre lo veamos.
Para una persona, hola mundo y hola mundo con un salto de
línea al final pueden parecer casi lo mismo. Para un hash, son contenidos diferentes.
Si el archivo contiene exactamente hola mundo, sin salto de línea final,
tenemos 10 bytes. Pero si contiene hola mundo más un salto de línea,
tenemos 11 bytes.
hola mundo -> 10 bytes
hola mundo\n -> 11 bytes
En Linux o macOS, para crear un archivo sin salto de línea final, usaremos
printf. Así controlamos mejor los bytes que entran al archivo.
printf "hola mundo" > nota.txt
En PowerShell también podemos crear el archivo sin salto de línea final escribiendo directamente los bytes UTF-8. Esta forma es más larga, pero evita sorpresas cuando queramos comparar nuestro hash con el de Git real.
[System.IO.File]::WriteAllBytes("nota.txt", [System.Text.Encoding]::UTF8.GetBytes("hola mundo"))
echo "hola mundo" > nota.txt para
las pruebas de hash de este tutorial. Dependiendo de la terminal, ese comando puede
agregar un salto de línea al final del archivo. Si eso ocurre, el hash no coincidirá
con el ejemplo esperado.
Esta precisión importa porque MiniGit calculará el hash sobre el objeto completo: cabecera, separador y contenido. Si cambia un solo byte del contenido, también cambia el objeto, y por tanto cambia el hash.
Esa es una de las ideas más importantes del modelo de Git: la identidad del contenido no depende del nombre del archivo ni de nuestra interpretación visual, sino de una secuencia exacta de bytes.
Idea clave
Para probar hashes correctamente, necesitamos controlar los bytes exactos del archivo.
Un salto de línea invisible puede convertir hola mundo en otro contenido
distinto para MiniGit y para Git real.
6. Por qué usamos SHA-1
Ya sabemos que el hash depende de bytes exactos. Ahora falta responder una pregunta importante: ¿qué algoritmo vamos a usar para calcular ese hash?
En este tutorial usaremos SHA-1. La razón principal no es que sea el
algoritmo más moderno, sino que Git históricamente construyó su modelo de objetos
alrededor de hashes SHA-1. Como estamos creando un MiniGit educativo, usar SHA-1 nos
ayuda a entender mejor la idea original.
Dicho de forma sencilla: vamos a usar SHA-1 porque queremos aprender cómo Git identifica contenido, guarda objetos y conecta commits. Nuestro objetivo aquí es pedagógico.
Cuando Git calcula el hash de un objeto tipo blob, no toma solamente el
contenido visible del archivo. Primero construye el objeto completo con su cabecera,
su separador y sus bytes de contenido. Luego calcula el SHA-1 de esa representación.
Por eso, en MiniGit seguiremos la misma idea: construiremos un objeto con este formato mental:
blob <tamaño>\0<contenido>
Después de construir ese objeto, calcularemos el hash SHA-1 sobre todos sus bytes. Así, el identificador no dependerá del nombre del archivo, ni de la carpeta, ni de una descripción escrita por nosotros. Dependerá directamente del contenido representado como objeto.
Esta diferencia es muy importante: el hash no es una etiqueta manual. Es una consecuencia matemática del objeto. Si el objeto cambia, aunque sea por un solo byte, el hash cambia.
Esto no significa que SHA-1 sea ideal para cualquier sistema actual. En contextos de seguridad moderna, autenticación, firmas digitales o protección contra atacantes, se prefieren algoritmos más fuertes. Pero ese no es el problema que estamos resolviendo en esta parte del tutorial.
Aquí nos interesa una idea más específica: identidad por contenido. Queremos que un
conjunto exacto de bytes produzca un identificador estable, y que podamos usar ese
identificador para guardar y recuperar objetos dentro de .minigit/objects.
Idea clave
En este MiniGit usamos SHA-1 para entender el modelo histórico de objetos
de Git. El objetivo no es seguridad criptográfica moderna, sino aprender cómo el
contenido exacto de un objeto puede convertirse en un identificador.
Más adelante, cuando escribamos el archivo objects.rs, esta idea aparecerá
en una función concreta: recibiremos bytes, los pasaremos por SHA-1 y devolveremos el
resultado como texto hexadecimal.
Con esto ya tenemos las tres ideas necesarias antes de crear el proyecto Rust: los archivos se leen como bytes, los bytes se envuelven en objetos y los objetos reciben una identidad mediante un hash.
7. Crear el proyecto Rust
Ya tenemos el modelo mental listo: archivos como bytes, bytes envueltos en objetos,
objetos identificados con SHA-1 y una carpeta interna llamada .minigit
donde guardaremos la información.
Ahora vamos a crear el proyecto Rust que implementará esas ideas. Nuestro programa se
llamará minigit y será una aplicación de consola. La ejecutaremos usando
cargo run -- seguido del comando que queramos probar.
En esta parte solo vamos a crear el proyecto base y comprobar que Rust está funcionando. Después agregaremos dependencias y separaremos el código en varios archivos.
cargo new minigit
Ese comando crea una carpeta nueva llamada minigit con la estructura mínima
de un proyecto Rust. Dentro encontraremos un archivo Cargo.toml y una carpeta
src con el archivo main.rs.
Ahora entramos a la carpeta del proyecto.
cd minigit
Antes de modificar nada, conviene ejecutar el proyecto tal como lo genera Cargo. Esto nos permite confirmar que Rust, Cargo y la estructura inicial están funcionando correctamente.
cargo run
Compiling minigit v0.1.0
Finished `dev` profile [unoptimized + debuginfo] target(s)
Running `target/debug/minigit`
Hello, world!
Si ves Hello, world!, el proyecto base está funcionando. Todavía no tenemos
comandos propios, ni objetos, ni carpeta .minigit, pero ya tenemos el punto
de partida para construirlos.
La estructura inicial debe verse parecida a esta:
minigit/
├── Cargo.toml
└── src/
└── main.rs
Verificación de esta parte
En este punto ya debes tener una carpeta minigit, un archivo
Cargo.toml, un archivo src/main.rs y un proyecto Rust que
compila correctamente con cargo run.
En la siguiente parte vamos a modificar Cargo.toml para agregar las
dependencias que necesita MiniGit: una para calcular hashes SHA-1 y otra para manejar
fechas en los commits.
8. Configurar Cargo.toml
Ahora que el proyecto Rust ya existe y compila, vamos a preparar sus dependencias.
En Rust, el archivo Cargo.toml describe el paquete y declara las librerías
externas que el proyecto necesita para funcionar.
MiniGit necesita dos dependencias principales. La primera nos permitirá calcular hashes SHA-1 para identificar objetos. La segunda nos permitirá guardar una fecha legible dentro de cada commit mínimo.
Abre el archivo Cargo.toml y reemplaza su contenido por la siguiente
configuración.
[package]
name = "minigit"
version = "0.1.0"
edition = "2024"
[dependencies]
sha1 = "0.10"
chrono = "0.4"
La sección [package] describe nuestro proyecto. Ahí aparece el nombre del
paquete, la versión y la edición de Rust que estamos usando.
En este caso, el paquete se llama minigit, que será también el nombre
conceptual de nuestra herramienta de consola. La versión empieza en 0.1.0
porque es una primera implementación educativa.
La parte más importante para este tutorial está en [dependencies]. Allí
declaramos las librerías externas que usará el código.
La dependencia sha1 nos permitirá calcular el hash de cada objeto. La vamos
a usar cuando construyamos objetos tipo blob y también cuando creemos
commits mínimos.
En nuestro código, esta dependencia aparecerá dentro del archivo objects.rs.
Desde ahí construiremos una función que recibe bytes, los procesa con SHA-1 y devuelve
el resultado como una cadena hexadecimal.
La dependencia chrono nos servirá para generar fechas. Cada commit mínimo
tendrá una fecha de creación, y esa fecha hará que el historial sea más fácil de leer
cuando ejecutemos el comando log.
En el Git real, los commits contienen más metadatos, como autor, zona horaria y otra información. En MiniGit vamos a empezar con algo más simple: una fecha legible, un mensaje, el hash del contenido y, cuando exista, el hash del commit anterior.
Verificación de esta parte
En este punto, Cargo.toml ya tiene todo lo necesario para continuar:
sha1 para calcular identificadores de objetos y chrono para
agregar fechas a los commits.
Con las dependencias listas, el siguiente paso será ordenar la arquitectura del proyecto.
En lugar de dejar todo dentro de main.rs, separaremos el código en archivos
con responsabilidades claras: comandos, repositorio, objetos y commits.
10. Crear main.rs
Ahora sí vamos a empezar a escribir el código real de MiniGit. El primer archivo será
src/main.rs, porque es el punto de entrada de cualquier aplicación Rust.
Este archivo no tendrá la lógica completa del proyecto. No calculará hashes, no guardará objetos y no creará commits directamente. Su trabajo será más simple: declarar los módulos del programa, definir un tipo común para los resultados y llamar al coordinador principal de comandos.
La idea es que main.rs sea pequeño y fácil de leer. Cuando el programa
arranque, delegará el trabajo en commands::run(), y desde ahí se decidirá
qué hacer según el comando que el usuario escriba en la terminal.
Archivo Rust
src/main.rs
Punto de entrada del programa. Declara los módulos principales, define el tipo
AppResult y ejecuta el flujo principal de comandos.
mod commands;
mod commit;
mod objects;
mod repository;
pub type AppResult<T> = Result<T, Box<dyn std::error::Error>>;
fn main() {
if let Err(error) = commands::run() {
eprintln!("Error: {error}");
std::process::exit(1);
}
}
Las primeras líneas declaran los módulos que tendrá el proyecto. Cada módulo corresponde a uno de los archivos que organizamos en la arquitectura.
commands será el módulo encargado de leer los argumentos de consola y decidir
qué comando ejecutar. commit manejará la creación y lectura de commits
mínimos. objects trabajará con blobs, hashes y objetos guardados.
repository manejará la carpeta interna .minigit.
En Rust, declarar un módulo con mod commands; le indica al compilador que
debe buscar un archivo llamado commands.rs dentro de la carpeta
src.
Después aparece una línea importante:
pub type AppResult<T> = Result<T, Box<dyn std::error::Error>>;
Esta línea crea un alias para manejar resultados en toda la aplicación. En vez de escribir
una firma larga cada vez que una función pueda fallar, usaremos AppResult.
El tipo Result en Rust representa dos posibilidades: que la operación salga
bien o que ocurra un error. En MiniGit muchas operaciones pueden fallar: leer un archivo,
crear una carpeta, abrir un objeto, validar un hash o escribir una referencia.
Usar Box<dyn std::error::Error> nos permite manejar distintos tipos de
errores con una misma forma general. Para un proyecto educativo como este, eso mantiene
el código más limpio y nos deja concentrarnos en el modelo interno de Git.
La función main llama a commands::run(). Esa será la función
que construiremos en la siguiente parte para interpretar los comandos escritos en la
terminal.
Si commands::run() devuelve un error, lo mostramos con eprintln!.
A diferencia de println!, esta macro escribe en la salida de errores, que
es el lugar correcto para reportar problemas del programa.
Después usamos std::process::exit(1) para terminar el programa indicando que
algo salió mal. En aplicaciones de consola, un código de salida diferente de cero suele
representar una ejecución fallida.
Qué logramos con main.rs
Ya tenemos un punto de entrada limpio para MiniGit. El archivo main.rs
declara los módulos, define una forma común de manejar errores y delega la ejecución
principal en commands::run().
Todavía falta crear los demás archivos, pero la estructura principal del programa ya está preparada.
En la siguiente parte construiremos commands.rs. Ahí MiniGit empezará a leer
argumentos de consola y a reconocer comandos como init, hash,
write, show, commit y log.
11. Crear commands.rs
Ya tenemos un main.rs pequeño que delega la ejecución principal en
commands::run(). Ahora necesitamos crear ese módulo.
El archivo src/commands.rs será el coordinador de la aplicación. Su trabajo
será leer lo que el usuario escribió en la terminal, identificar el comando solicitado y
llamar a la función correspondiente.
Este archivo no debe encargarse de calcular hashes, guardar objetos o crear commits por
su cuenta. Para mantener el proyecto ordenado, commands.rs solo decide qué
acción ejecutar y delega el trabajo real en otros módulos.
Archivo Rust
src/commands.rs
Lee los argumentos de consola, reconoce comandos como init,
hash, write, show, commit
y log, y delega cada acción en el módulo correspondiente.
use std::env;
use crate::AppResult;
use crate::commit;
use crate::objects;
use crate::repository;
pub fn run() -> AppResult<()> {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
print_help();
return Ok(());
}
match args[1].as_str() {
"init" => {
repository::init_repo()?;
}
"hash" => {
if args.len() < 3 {
return Err("Uso: minigit hash <archivo>".into());
}
let file_path = &args[2];
let hash = objects::hash_file_as_git_blob(file_path)?;
println!("{hash}");
}
"write" => {
if args.len() < 3 {
return Err("Uso: minigit write <archivo>".into());
}
let file_path = &args[2];
let hash = objects::write_file_as_git_blob(file_path)?;
println!("Objeto guardado: {hash}");
}
"show" => {
if args.len() < 3 {
return Err("Uso: minigit show <hash>".into());
}
let hash = &args[2];
objects::show_object(hash)?;
}
"commit" => {
if args.len() < 5 {
return Err("Uso: minigit commit <archivo> -m \"mensaje\"".into());
}
let file_path = &args[2];
if args[3] != "-m" {
return Err("Uso: minigit commit <archivo> -m \"mensaje\"".into());
}
let message = args[4..].join(" ");
if message.trim().is_empty() {
return Err("El mensaje del commit no puede estar vacío".into());
}
let commit_hash = commit::commit_file(file_path, &message)?;
println!("Commit creado: {commit_hash}");
}
"log" => {
commit::log_commits()?;
}
"help" | "-h" | "--help" => {
print_help();
}
command => {
return Err(format!("Comando desconocido: {command}").into());
}
}
Ok(())
}
fn print_help() {
println!("minigit - un git mínimo escrito en Rust");
println!();
println!("Uso:");
println!(" minigit <comando>");
println!();
println!("Comandos disponibles:");
println!(" init Crea un repositorio .minigit");
println!(" hash <archivo> Calcula el hash tipo Git de un archivo");
println!(" write <archivo> Guarda un objeto blob en .minigit/objects");
println!(" show <hash> Muestra el contenido de un objeto blob");
println!(" commit <archivo> -m \"msg\" Crea un commit mínimo apuntando a un blob");
println!(" log Muestra el historial de commits");
}
La primera línea importa std::env. Ese módulo de la librería estándar nos
permite leer los argumentos que llegan desde la terminal.
Luego importamos AppResult, que fue definido en main.rs. Así
todas las funciones principales del proyecto pueden devolver errores de una forma común.
También importamos los módulos commit, objects y
repository. Esto es importante porque commands.rs no hará el
trabajo pesado directamente: llamará a las funciones que viven en esos archivos.
La función principal de este archivo es run(). Dentro de ella leemos los
argumentos de consola y los guardamos en un vector de textos.
let args: Vec<String> = env::args().collect();
Cuando ejecutamos el programa, Rust recibe varios argumentos. El primero suele ser la
ruta del ejecutable. El segundo será el comando que queremos ejecutar, como
init, hash o log.
Por eso revisamos si hay menos de dos argumentos. Si el usuario no escribió ningún comando, mostramos la ayuda y terminamos sin error.
if args.len() < 2 {
print_help();
return Ok(());
}
Después usamos un match sobre args[1]. Esa posición contiene el
comando principal que el usuario escribió después del nombre del programa.
La llamada a as_str() nos permite comparar ese texto con cadenas como
init, hash, write, show,
commit y log.
match args[1].as_str() {
"init" => {
repository::init_repo()?;
}
command => {
return Err(format!("Comando desconocido: {command}").into());
}
}
El patrón general será siempre el mismo: reconocer el comando, validar que tenga los datos necesarios y llamar a una función de otro módulo.
Por ejemplo, cuando el usuario pide init, llamamos a
repository::init_repo(). Ese módulo será el encargado de crear la carpeta
.minigit y su estructura interna.
Cuando el usuario pide hash, validamos que haya indicado un archivo y luego
llamamos a objects::hash_file_as_git_blob(). Así mantenemos separada la
lectura de comandos de la lógica de objetos.
Estos son los comandos que reconocerá MiniGit en esta primera versión:
init
hash <archivo>
write <archivo>
show <hash>
commit <archivo> -m "mensaje"
log
help
-h
--help
El comando init inicializará el repositorio educativo. El comando
hash calculará el hash tipo Git de un archivo sin guardarlo todavía.
El comando write calculará el hash y además guardará el objeto dentro de
.minigit/objects. El comando show hará el proceso inverso:
buscará un objeto por hash y mostrará su contenido.
El comando commit creará un commit mínimo apuntando a un blob, y
log recorrerá la historia desde el último commit hacia atrás.
También aceptamos help, -h y --help para mostrar
una guía rápida de uso.
La parte de commit tiene una validación adicional. No basta con indicar el
archivo: también esperamos una marca -m y un mensaje.
Esto imita la forma común de escribir un mensaje de commit desde la terminal. Para este MiniGit no vamos a construir un editor interactivo ni una interfaz avanzada. Solo recibiremos el mensaje desde los argumentos.
La línea args[4..].join(" ") permite unir todas las palabras del mensaje.
Así, si el mensaje tiene varios términos, se conserva como una sola frase.
let message = args[4..].join(" ");
También validamos que el mensaje no esté vacío. Un commit sin mensaje no sería útil para entender la historia del proyecto.
if message.trim().is_empty() {
return Err("El mensaje del commit no puede estar vacío".into());
}
Al final del archivo aparece la función print_help(). Esta función no cambia
el repositorio ni toca objetos. Solo imprime una ayuda sencilla para que el usuario sepa
qué comandos existen.
Tener una ayuda básica desde el inicio hace que la herramienta sea más amable de usar. Si alguien ejecuta MiniGit sin argumentos, no recibirá un error extraño: verá una guía rápida con los comandos disponibles.
Qué logramos con commands.rs
Ahora MiniGit ya tiene un coordinador de comandos. El programa puede leer argumentos de consola, reconocer acciones y delegar cada trabajo en el módulo correcto.
Todavía falta implementar repository.rs, objects.rs y
commit.rs, pero la entrada de comandos ya quedó definida.
En la siguiente parte construiremos repository.rs. Ese archivo será el
encargado de crear la carpeta .minigit, preparar objects,
crear refs/heads/main y guardar el puntero al último commit.
12. Crear repository.rs
Ya tenemos un archivo commands.rs capaz de reconocer comandos como
init, hash, write, show,
commit y log. Pero todavía falta una pieza clave:
necesitamos una carpeta interna donde MiniGit pueda guardar su información.
Esa responsabilidad estará en src/repository.rs. Este archivo se encargará
de crear y validar la estructura interna de MiniGit: la carpeta .minigit,
la carpeta de objetos, el archivo HEAD y la referencia principal
refs/heads/main.
En otras palabras, repository.rs será el módulo que conoce dónde vive el
repositorio por dentro.
En Git real, cuando ejecutamos git init, aparece una carpeta
.git. Esa carpeta contiene toda la información interna del repositorio:
objetos, referencias, configuración, ramas, historial y muchas estructuras más.
Nuestro MiniGit usará una idea parecida, pero mucho más pequeña. En lugar de crear
.git, crearemos una carpeta llamada .minigit.
Esa carpeta será nuestro laboratorio. Ahí guardaremos los objetos identificados por hash y también la referencia que apunta al último commit.
.minigit/
├── HEAD
├── objects/
└── refs/
└── heads/
└── main
La carpeta objects guardará los objetos de MiniGit. Primero guardaremos
objetos tipo blob, y más adelante también guardaremos objetos tipo
commit.
El archivo HEAD indicará cuál es la referencia principal del repositorio.
En esta primera versión apuntará siempre a refs/heads/main.
El archivo refs/heads/main guardará el hash del último commit. Si todavía
no hay commits, ese archivo existirá, pero estará vacío.
Archivo Rust
src/repository.rs
Crea la carpeta interna .minigit, valida que el repositorio exista,
construye rutas hacia objetos y actualiza la referencia principal
refs/heads/main.
use std::fs;
use std::path::{Path, PathBuf};
use crate::AppResult;
use crate::objects;
pub const REPO_DIR: &str = ".minigit";
pub fn init_repo() -> AppResult<()> {
let repo_path = Path::new(REPO_DIR);
let objects_path = repo_path.join("objects");
let refs_heads_path = repo_path.join("refs").join("heads");
let head_path = repo_path.join("HEAD");
let main_ref_path = refs_heads_path.join("main");
fs::create_dir_all(&objects_path)?;
fs::create_dir_all(&refs_heads_path)?;
if !head_path.exists() {
fs::write(&head_path, "ref: refs/heads/main\n")?;
}
if !main_ref_path.exists() {
fs::write(&main_ref_path, "")?;
}
println!("Repositorio .minigit creado correctamente.");
println!("HEAD -> refs/heads/main");
Ok(())
}
pub fn ensure_repo_initialized() -> AppResult<()> {
let repo_path = Path::new(REPO_DIR);
let objects_path = repo_path.join("objects");
let refs_heads_path = repo_path.join("refs").join("heads");
let head_path = repo_path.join("HEAD");
let main_ref_path = refs_heads_path.join("main");
if !repo_path.exists()
|| !objects_path.exists()
|| !refs_heads_path.exists()
|| !head_path.exists()
{
return Err(
"No estás dentro de un repositorio .minigit. Ejecuta primero: cargo run -- init".into(),
);
}
if !main_ref_path.exists() {
fs::write(main_ref_path, "")?;
}
Ok(())
}
pub fn object_path_from_hash(hash: &str) -> PathBuf {
Path::new(REPO_DIR).join("objects").join(hash)
}
pub fn main_ref_path() -> PathBuf {
Path::new(REPO_DIR).join("refs").join("heads").join("main")
}
pub fn read_current_head_hash() -> AppResult<Option<String>> {
let main_ref_path = main_ref_path();
if !main_ref_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(main_ref_path)?;
let hash = content.trim();
if hash.is_empty() {
Ok(None)
} else {
objects::validate_hash(hash)?;
Ok(Some(hash.to_string()))
}
}
pub fn update_current_head(commit_hash: &str) -> AppResult<()> {
objects::validate_hash(commit_hash)?;
let main_ref_path = main_ref_path();
fs::write(main_ref_path, format!("{commit_hash}\n"))?;
Ok(())
}
Lo primero que aparece en este archivo son las importaciones. Usamos std::fs
para crear carpetas, leer archivos y escribir datos en disco.
También usamos Path y PathBuf. Estos tipos nos permiten
construir rutas de archivos de una forma más segura que escribir rutas completas como
texto manualmente.
Después importamos AppResult, porque las operaciones de este archivo pueden
fallar. Crear carpetas, leer referencias o escribir archivos son acciones que pueden
producir errores.
pub const REPO_DIR: &str = ".minigit";
La constante REPO_DIR guarda el nombre de la carpeta interna del repositorio.
Así evitamos repetir el texto .minigit en varias partes del código.
Si más adelante quisiéramos cambiar el nombre de la carpeta interna, tendríamos un solo lugar claro donde hacerlo.
12.1 La función init_repo()
La función init_repo() será llamada cuando el usuario ejecute el comando
init. Su trabajo es crear la estructura mínima de MiniGit.
Primero construimos las rutas internas que necesitamos: la carpeta de objetos, la carpeta
de referencias, el archivo HEAD y la referencia principal
main.
let repo_path = Path::new(REPO_DIR);
let objects_path = repo_path.join("objects");
let refs_heads_path = repo_path.join("refs").join("heads");
let head_path = repo_path.join("HEAD");
let main_ref_path = refs_heads_path.join("main");
Después creamos las carpetas necesarias con fs::create_dir_all(). Esta
función es útil porque puede crear una ruta completa aunque algunas carpetas intermedias
todavía no existan.
fs::create_dir_all(&objects_path)?;
fs::create_dir_all(&refs_heads_path)?;
Luego escribimos el archivo HEAD, pero solo si todavía no existe. En esta
versión de MiniGit, HEAD apuntará siempre a refs/heads/main.
if !head_path.exists() {
fs::write(&head_path, "ref: refs/heads/main\n")?;
}
También creamos el archivo refs/heads/main si no existe. Al principio queda
vacío, porque todavía no hay commits.
Más adelante, cuando creemos un commit, este archivo guardará el hash del commit más reciente.
if !main_ref_path.exists() {
fs::write(&main_ref_path, "")?;
}
12.2 Validar que el repositorio existe
No todos los comandos deberían ejecutarse en cualquier carpeta. Por ejemplo, no tendría
sentido guardar un objeto si antes no se ha creado la carpeta .minigit.
Para eso usamos ensure_repo_initialized(). Esta función revisa que la
estructura mínima del repositorio exista antes de continuar.
if !repo_path.exists()
|| !objects_path.exists()
|| !refs_heads_path.exists()
|| !head_path.exists()
{
return Err(
"No estás dentro de un repositorio .minigit. Ejecuta primero: cargo run -- init".into(),
);
}
Si falta alguna parte importante, devolvemos un error claro. Así el usuario entiende que
primero debe ejecutar init.
Esta validación será usada por comandos como write, show,
commit y log, porque todos dependen de que el repositorio ya
exista.
12.3 Construir rutas para objetos
La función object_path_from_hash() recibe un hash y devuelve la ruta donde
debería vivir ese objeto dentro de MiniGit.
pub fn object_path_from_hash(hash: &str) -> PathBuf {
Path::new(REPO_DIR).join("objects").join(hash)
}
Por ejemplo, si un objeto tiene un hash determinado, MiniGit lo guardará dentro de
.minigit/objects usando ese hash como nombre de archivo.
.minigit/objects/<hash>
Esto conecta directamente con el modelo mental que venimos construyendo: el contenido se convierte en objeto, el objeto recibe un hash y ese hash se usa como dirección dentro del repositorio.
12.4 La ruta de refs/heads/main
La función main_ref_path() devuelve la ruta de la referencia principal de
MiniGit.
pub fn main_ref_path() -> PathBuf {
Path::new(REPO_DIR).join("refs").join("heads").join("main")
}
En esta primera versión solo tendremos una referencia principal: main. No
construiremos ramas reales todavía, pero este archivo nos permite guardar el hash del
último commit.
Cuando ejecutemos log, MiniGit empezará leyendo esta referencia para saber
desde qué commit debe comenzar el recorrido del historial.
12.5 Leer el último commit
La función read_current_head_hash() lee el archivo
refs/heads/main y devuelve el hash que encuentre allí.
Como al principio puede no existir ningún commit, esta función no devuelve siempre un
hash. Por eso su resultado interno es un Option<String>.
pub fn read_current_head_hash() -> AppResult<Option<String>> {
let main_ref_path = main_ref_path();
if !main_ref_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(main_ref_path)?;
let hash = content.trim();
if hash.is_empty() {
Ok(None)
} else {
objects::validate_hash(hash)?;
Ok(Some(hash.to_string()))
}
}
Si el archivo no existe o está vacío, devolvemos None. Eso significa que no
hay un último commit todavía.
Si sí existe contenido, quitamos espacios y saltos de línea con trim(),
validamos que parezca un hash correcto y devolvemos el valor.
Esta validación evita que MiniGit siga una referencia corrupta o mal escrita.
12.6 Actualizar la referencia principal
La función update_current_head() será usada después de crear un commit.
Su trabajo es escribir el hash del nuevo commit dentro de
refs/heads/main.
pub fn update_current_head(commit_hash: &str) -> AppResult<()> {
objects::validate_hash(commit_hash)?;
let main_ref_path = main_ref_path();
fs::write(main_ref_path, format!("{commit_hash}\n"))?;
Ok(())
}
Antes de escribir el hash, llamamos a objects::validate_hash(). Así nos
aseguramos de no guardar cualquier texto como si fuera una referencia válida.
Después escribimos el hash en refs/heads/main. Desde ese momento, MiniGit
considera que ese commit es el más reciente.
refs/heads/main
↓
último commit
Qué logramos con repository.rs
Ahora MiniGit sabe crear su estructura interna, validar que el repositorio exista, construir rutas hacia objetos y guardar la referencia al último commit.
Esta parte es fundamental porque los siguientes módulos necesitan una base física en
disco. Sin .minigit, no habría dónde guardar objetos ni desde dónde leer
el historial.
Con repository.rs listo, ya tenemos el lugar donde MiniGit guardará su
información. El siguiente paso será construir el corazón del modelo de objetos.
En la próxima parte crearemos objects.rs. Ahí implementaremos la lógica para
leer archivos como bytes, construir objetos tipo blob, calcular SHA-1,
guardar objetos y mostrarlos desde un hash.
13. Crear objects.rs
Ya tenemos la carpeta interna .minigit y las rutas donde MiniGit guardará
su información. Ahora vamos a construir una de las partes más importantes del proyecto:
el módulo de objetos.
El archivo src/objects.rs será el corazón de esta primera versión. Aquí
leeremos archivos como bytes, construiremos objetos tipo blob, calcularemos
hashes SHA-1, guardaremos objetos dentro de .minigit/objects y podremos
recuperar contenido usando un hash.
Esta es la parte donde el modelo mental empieza a convertirse en código real:
archivo
↓
bytes exactos
↓
blob <tamaño>\0<contenido>
↓
SHA-1
↓
.minigit/objects/<hash>
Antes habíamos dicho que Git no identifica un archivo por su nombre, sino por el contenido convertido en objeto. En esta parte implementaremos exactamente esa idea.
Archivo Rust
src/objects.rs
Construye objetos tipo blob, calcula hashes SHA-1, guarda objetos en
.minigit/objects, valida hashes y muestra el contenido almacenado.
use sha1::{Digest, Sha1};
use std::fs;
use std::io::{self, Write};
use crate::AppResult;
use crate::repository;
pub fn hash_file_as_git_blob(file_path: &str) -> AppResult<String> {
let content = fs::read(file_path)?;
let object = build_blob_object(&content);
let hash = sha1_hex(&object);
Ok(hash)
}
pub fn write_file_as_git_blob(file_path: &str) -> AppResult<String> {
repository::ensure_repo_initialized()?;
let content = fs::read(file_path)?;
let object = build_blob_object(&content);
let hash = sha1_hex(&object);
let object_path = repository::object_path_from_hash(&hash);
if !object_path.exists() {
fs::write(&object_path, object)?;
}
Ok(hash)
}
pub fn show_object(hash: &str) -> AppResult<()> {
repository::ensure_repo_initialized()?;
validate_hash(hash)?;
let object_path = repository::object_path_from_hash(hash);
if !object_path.exists() {
return Err(format!("El objeto no existe: {hash}").into());
}
let object = fs::read(object_path)?;
let (object_type, size, content) = parse_object(&object)?;
if object_type != "blob" {
return Err(format!("Tipo de objeto no soportado por show todavía: {object_type}").into());
}
if content.len() != size {
return Err(format!(
"Objeto corrupto: la cabecera dice {size} bytes, pero el contenido tiene {} bytes",
content.len()
)
.into());
}
let mut stdout = io::stdout();
stdout.write_all(content)?;
stdout.flush()?;
Ok(())
}
pub(crate) fn build_blob_object(content: &[u8]) -> Vec<u8> {
build_object("blob", content)
}
pub(crate) fn build_object(object_type: &str, content: &[u8]) -> Vec<u8> {
let header = format!("{object_type} {}\0", content.len());
let mut object = Vec::new();
object.extend_from_slice(header.as_bytes());
object.extend_from_slice(content);
object
}
pub(crate) fn parse_object(object: &[u8]) -> AppResult<(&str, usize, &[u8])> {
let separator_position = object
.iter()
.position(|byte| *byte == 0)
.ok_or("Objeto corrupto: no tiene separador \\0")?;
let header_bytes = &object[..separator_position];
let content = &object[separator_position + 1..];
let header = std::str::from_utf8(header_bytes)?;
let mut parts = header.split(' ');
let object_type = parts
.next()
.ok_or("Objeto corrupto: falta el tipo de objeto")?;
let size_text = parts
.next()
.ok_or("Objeto corrupto: falta el tamaño del objeto")?;
if parts.next().is_some() {
return Err("Objeto corrupto: la cabecera tiene demasiadas partes".into());
}
let size: usize = size_text.parse()?;
Ok((object_type, size, content))
}
pub(crate) fn sha1_hex(bytes: &[u8]) -> String {
let mut hasher = Sha1::new();
hasher.update(bytes);
let result = hasher.finalize();
result.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub(crate) fn validate_hash(hash: &str) -> AppResult<()> {
if hash.len() != 40 {
return Err("El hash debe tener 40 caracteres hexadecimales".into());
}
if !hash.chars().all(|character| character.is_ascii_hexdigit()) {
return Err("El hash solo puede contener caracteres hexadecimales".into());
}
Ok(())
}
Empecemos por las importaciones. Usamos sha1 para calcular el identificador
de cada objeto. También usamos std::fs para leer y escribir archivos en
disco.
Además importamos std::io y Write porque el comando
show escribirá bytes directamente en la salida de la terminal. Esto es
importante porque un objeto puede contener bytes que no necesariamente queremos convertir
primero a texto.
Finalmente importamos repository, porque este módulo necesita saber dónde
guardar los objetos dentro de .minigit/objects.
13.1 Calcular el hash de un archivo
La función hash_file_as_git_blob() calcula el hash tipo Git de un archivo,
pero no lo guarda todavía. Solo lee el archivo, construye el objeto tipo
blob y calcula su SHA-1.
pub fn hash_file_as_git_blob(file_path: &str) -> AppResult<String> {
let content = fs::read(file_path)?;
let object = build_blob_object(&content);
let hash = sha1_hex(&object);
Ok(hash)
}
La línea fs::read(file_path)? lee el archivo como bytes. Esto es exactamente
lo que necesitamos, porque el hash no depende de una interpretación visual del texto,
sino de los bytes reales del archivo.
Después llamamos a build_blob_object(). Esa función toma los bytes del
archivo y los envuelve con la cabecera del objeto.
blob <tamaño>\0<contenido>
Finalmente, sha1_hex() calcula el SHA-1 del objeto completo y devuelve el
resultado como texto hexadecimal.
Esta función será usada por el comando hash. Por eso, cuando el usuario
ejecute MiniGit para calcular el hash de un archivo, todavía no se escribirá nada dentro
de .minigit/objects.
13.2 Guardar un objeto blob
La función write_file_as_git_blob() hace casi lo mismo que la función
anterior, pero con una diferencia fundamental: además de calcular el hash, guarda el
objeto dentro de .minigit/objects.
pub fn write_file_as_git_blob(file_path: &str) -> AppResult<String> {
repository::ensure_repo_initialized()?;
let content = fs::read(file_path)?;
let object = build_blob_object(&content);
let hash = sha1_hex(&object);
let object_path = repository::object_path_from_hash(&hash);
if !object_path.exists() {
fs::write(&object_path, object)?;
}
Ok(hash)
}
Lo primero que hacemos es validar que el repositorio exista. No tendría sentido guardar
un objeto si el usuario todavía no ha ejecutado init.
repository::ensure_repo_initialized()?;
Luego leemos el archivo, construimos el objeto tipo blob y calculamos el
hash sobre el objeto completo.
let content = fs::read(file_path)?;
let object = build_blob_object(&content);
let hash = sha1_hex(&object);
Después usamos el hash para construir la ruta donde vivirá el objeto.
let object_path = repository::object_path_from_hash(&hash);
Si el objeto todavía no existe, lo escribimos en disco. Si ya existe, no hacemos nada. Esto es una idea muy importante: dos archivos con el mismo contenido producen el mismo objeto y el mismo hash.
if !object_path.exists() {
fs::write(&object_path, object)?;
}
Así MiniGit evita duplicar contenido idéntico. No importa si el archivo se llama
nota.txt, copia.txt o apuntes.txt. Si los bytes
son los mismos, el objeto será el mismo.
Idea importante
MiniGit no guarda objetos por nombre de archivo. Los guarda por hash. El nombre del
archivo no forma parte del objeto tipo blob; lo que importa aquí son los
bytes exactos del contenido.
13.3 Mostrar un objeto guardado
La función show_object() hace el camino contrario. Recibe un hash, busca el
objeto correspondiente dentro de .minigit/objects, valida su estructura y
muestra el contenido original.
pub fn show_object(hash: &str) -> AppResult<()> {
repository::ensure_repo_initialized()?;
validate_hash(hash)?;
let object_path = repository::object_path_from_hash(hash);
if !object_path.exists() {
return Err(format!("El objeto no existe: {hash}").into());
}
let object = fs::read(object_path)?;
let (object_type, size, content) = parse_object(&object)?;
if object_type != "blob" {
return Err(format!("Tipo de objeto no soportado por show todavía: {object_type}").into());
}
if content.len() != size {
return Err(format!(
"Objeto corrupto: la cabecera dice {size} bytes, pero el contenido tiene {} bytes",
content.len()
)
.into());
}
let mut stdout = io::stdout();
stdout.write_all(content)?;
stdout.flush()?;
Ok(())
}
Primero validamos que el repositorio exista y que el hash tenga una forma correcta. En esta versión esperamos hashes de 40 caracteres hexadecimales, que es el tamaño típico de una representación SHA-1.
repository::ensure_repo_initialized()?;
validate_hash(hash)?;
Luego construimos la ruta del objeto usando el hash. Si no existe ningún archivo con ese nombre, devolvemos un error claro.
let object_path = repository::object_path_from_hash(hash);
if !object_path.exists() {
return Err(format!("El objeto no existe: {hash}").into());
}
Si el objeto existe, lo leemos como bytes y lo analizamos con parse_object().
Esa función separará la cabecera del contenido.
let object = fs::read(object_path)?;
let (object_type, size, content) = parse_object(&object)?;
Por ahora, show solo soportará objetos tipo blob. Más adelante
también tendremos objetos tipo commit, pero no tendría sentido mostrarlos
con esta función como si fueran contenido de archivo.
if object_type != "blob" {
return Err(format!("Tipo de objeto no soportado por show todavía: {object_type}").into());
}
También verificamos que el tamaño declarado en la cabecera coincida con el tamaño real del contenido. Si no coincide, el objeto está corrupto o fue modificado manualmente.
if content.len() != size {
return Err(format!(
"Objeto corrupto: la cabecera dice {size} bytes, pero el contenido tiene {} bytes",
content.len()
)
.into());
}
Al final escribimos los bytes del contenido directamente en la salida de la terminal.
Usamos write_all() para respetar los bytes originales del archivo guardado.
let mut stdout = io::stdout();
stdout.write_all(content)?;
stdout.flush()?;
13.4 Construir un blob
La función build_blob_object() es pequeña, pero representa una idea enorme:
convertir contenido normal de un archivo en un objeto tipo blob.
pub(crate) fn build_blob_object(content: &[u8]) -> Vec<u8> {
build_object("blob", content)
}
Esta función no hace todo el trabajo directamente. Simplemente llama a
build_object() indicando que el tipo de objeto será blob.
La ventaja de esta decisión es que luego podremos reutilizar build_object()
para crear objetos de otro tipo, como commit.
13.5 Construir un objeto genérico
La función build_object() recibe un tipo de objeto y un contenido en bytes.
Con esos datos construye la representación interna que MiniGit va a hashear y guardar.
pub(crate) fn build_object(object_type: &str, content: &[u8]) -> Vec<u8> {
let header = format!("{object_type} {}\0", content.len());
let mut object = Vec::new();
object.extend_from_slice(header.as_bytes());
object.extend_from_slice(content);
object
}
Primero construimos la cabecera. Esa cabecera contiene el tipo de objeto, el tamaño del contenido y el separador nulo.
let header = format!("{object_type} {}\0", content.len());
Si el contenido fuera hola mundo, sin salto de línea final, el tamaño sería
10 bytes. La cabecera quedaría conceptualmente así:
blob 10\0
Después creamos un vector de bytes vacío y agregamos primero la cabecera y luego el contenido real.
blob 10\0hola mundo
Ese objeto completo será el que pasaremos por SHA-1. Por eso el hash depende tanto del contenido como de la cabecera que lo describe.
13.6 Leer un objeto guardado
Guardar objetos no basta. También necesitamos leerlos después. Para eso existe
parse_object(), que recibe los bytes de un objeto y separa la cabecera del
contenido.
pub(crate) fn parse_object(object: &[u8]) -> AppResult<(&str, usize, &[u8])> {
let separator_position = object
.iter()
.position(|byte| *byte == 0)
.ok_or("Objeto corrupto: no tiene separador \\0")?;
let header_bytes = &object[..separator_position];
let content = &object[separator_position + 1..];
let header = std::str::from_utf8(header_bytes)?;
let mut parts = header.split(' ');
let object_type = parts
.next()
.ok_or("Objeto corrupto: falta el tipo de objeto")?;
let size_text = parts
.next()
.ok_or("Objeto corrupto: falta el tamaño del objeto")?;
if parts.next().is_some() {
return Err("Objeto corrupto: la cabecera tiene demasiadas partes".into());
}
let size: usize = size_text.parse()?;
Ok((object_type, size, content))
}
La primera tarea es encontrar el separador nulo. Ese byte marca dónde termina la cabecera y dónde empieza el contenido.
let separator_position = object
.iter()
.position(|byte| *byte == 0)
.ok_or("Objeto corrupto: no tiene separador \\0")?;
Si no existe ese separador, el objeto no tiene el formato que esperamos. En ese caso, devolvemos un error porque el objeto está corrupto.
Después dividimos el objeto en dos partes: cabecera y contenido.
let header_bytes = &object[..separator_position];
let content = &object[separator_position + 1..];
La cabecera sí debe poder interpretarse como texto UTF-8, porque contiene algo como
blob 10. El contenido, en cambio, lo mantenemos como bytes.
let header = std::str::from_utf8(header_bytes)?;
Luego separamos la cabecera por espacios. Esperamos exactamente dos partes: el tipo del objeto y el tamaño del contenido.
let mut parts = header.split(' ');
let object_type = parts
.next()
.ok_or("Objeto corrupto: falta el tipo de objeto")?;
let size_text = parts
.next()
.ok_or("Objeto corrupto: falta el tamaño del objeto")?;
Si aparece una tercera parte en la cabecera, también lo tratamos como error. Eso nos ayuda a mantener el formato interno simple y estricto.
if parts.next().is_some() {
return Err("Objeto corrupto: la cabecera tiene demasiadas partes".into());
}
Finalmente convertimos el tamaño desde texto hacia número y devolvemos tres datos: el tipo del objeto, el tamaño declarado y el contenido.
let size: usize = size_text.parse()?;
Ok((object_type, size, content))
13.7 Calcular SHA-1 como texto hexadecimal
La función sha1_hex() recibe bytes y devuelve el hash SHA-1 como texto
hexadecimal. Esta es la función que convierte un objeto en identificador.
pub(crate) fn sha1_hex(bytes: &[u8]) -> String {
let mut hasher = Sha1::new();
hasher.update(bytes);
let result = hasher.finalize();
result.iter().map(|byte| format!("{byte:02x}")).collect()
}
Primero creamos un hasher SHA-1. Luego le entregamos los bytes del objeto completo con
update().
let mut hasher = Sha1::new();
hasher.update(bytes);
Después llamamos a finalize() para obtener el resultado binario del hash.
let result = hasher.finalize();
Ese resultado todavía no está en la forma textual que esperamos ver en la terminal. Por eso convertimos cada byte a hexadecimal usando dos dígitos.
result.iter().map(|byte| format!("{byte:02x}")).collect()
El resultado final será una cadena de 40 caracteres hexadecimales. Esa cadena será el
nombre del archivo que guardaremos dentro de .minigit/objects.
13.8 Validar hashes
La función validate_hash() revisa que un hash tenga la forma esperada antes
de usarlo para leer objetos o actualizar referencias.
pub(crate) fn validate_hash(hash: &str) -> AppResult<()> {
if hash.len() != 40 {
return Err("El hash debe tener 40 caracteres hexadecimales".into());
}
if !hash.chars().all(|character| character.is_ascii_hexdigit()) {
return Err("El hash solo puede contener caracteres hexadecimales".into());
}
Ok(())
}
Primero revisamos la longitud. Un SHA-1 escrito en hexadecimal debe tener 40 caracteres.
if hash.len() != 40 {
return Err("El hash debe tener 40 caracteres hexadecimales".into());
}
Luego revisamos que todos los caracteres sean hexadecimales. Eso significa que solo
aceptamos números del 0 al 9 y letras de la a a la
f, además de sus equivalentes en mayúscula.
if !hash.chars().all(|character| character.is_ascii_hexdigit()) {
return Err("El hash solo puede contener caracteres hexadecimales".into());
}
Esta validación será útil cuando el usuario ejecute show <hash> y
también cuando MiniGit lea referencias internas como refs/heads/main.
Qué logramos con objects.rs
Ahora MiniGit puede leer un archivo como bytes, construir un objeto tipo
blob, calcular su hash SHA-1, guardarlo dentro de
.minigit/objects y recuperarlo después usando ese hash.
Esta parte completa el ciclo básico de objetos: contenido, cabecera, hash, archivo interno y recuperación.
archivo
↓
fs::read()
↓
build_blob_object()
↓
sha1_hex()
↓
.minigit/objects/<hash>
↓
show_object()
↓
contenido original
Con esto ya tenemos la base para los comandos hash, write y
show. Todavía no hemos probado esos comandos desde la terminal, pero el
código que los hace posibles ya está escrito.
Más adelante, cuando implementemos commits, reutilizaremos una parte muy importante de
este archivo: build_object() y sha1_hex(). Un commit también
será un objeto, solo que con otro tipo y otro contenido interno.
En la siguiente parte vamos a probar primero el comando init. Así podremos
confirmar que .minigit se crea correctamente antes de guardar objetos dentro
de esa carpeta.
14. Probar init
Ya escribimos los módulos principales para comandos, repositorio y objetos. Antes de probar hashes o guardar blobs, necesitamos verificar que MiniGit pueda crear su carpeta interna correctamente.
En esta parte vamos a ejecutar el comando init. Este comando debe crear la
carpeta .minigit, preparar la carpeta objects, crear
HEAD y dejar lista la referencia principal refs/heads/main.
main.rs ya declara
mod commit;, Rust necesita que exista el archivo src/commit.rs.
Todavía no vamos a implementar commits reales, pero sí crearemos una versión temporal
mínima para que el proyecto pueda compilar mientras probamos init.
Archivo Rust temporal
src/commit.rs
Versión mínima para que el proyecto compile antes de implementar commits reales. Más adelante reemplazaremos este contenido por la versión completa.
use crate::AppResult;
pub fn commit_file(_file_path: &str, _message: &str) -> AppResult<String> {
Err("El comando commit todavía no está implementado.".into())
}
pub fn log_commits() -> AppResult<()> {
println!("El comando log todavía no está implementado.");
Ok(())
}
Esta versión temporal no crea commits. Solo existe para que Rust encuentre el módulo
commit y pueda compilar el proyecto mientras probamos las partes anteriores.
Cuando lleguemos a la sección de commits, reemplazaremos este archivo por una versión completa que sí guardará commits, leerá padres y mostrará el historial.
Ahora sí ejecutemos el comando init desde la raíz del proyecto, es decir,
desde la carpeta donde está el archivo Cargo.toml.
cargo run -- init
Si todo está bien, Cargo compilará el proyecto y luego MiniGit imprimirá un mensaje indicando que el repositorio interno fue creado correctamente.
Repositorio .minigit creado correctamente.
HEAD -> refs/heads/main
Después de ejecutar el comando, debe aparecer una nueva carpeta llamada
.minigit. Esa carpeta será el espacio interno donde MiniGit guardará sus
objetos y referencias.
La estructura esperada es esta:
.minigit/
├── HEAD
├── objects/
└── refs/
└── heads/
└── main
La carpeta objects todavía estará vacía. Eso está bien. Aún no hemos
ejecutado write, así que MiniGit todavía no ha guardado ningún objeto tipo
blob.
El archivo HEAD debe contener una referencia hacia
refs/heads/main. En esta primera versión no tendremos ramas reales, pero
sí imitaremos la idea de tener una referencia principal.
ref: refs/heads/main
El archivo refs/heads/main también debe existir, pero al comienzo estará
vacío. Eso significa que el repositorio ya fue inicializado, pero todavía no tiene
commits.
Más adelante, cuando creemos el primer commit, MiniGit escribirá en ese archivo el hash del commit más reciente.
refs/heads/main
↓
vacío por ahora
Verificación de esta parte
Después de ejecutar cargo run -- init, deben existir estos elementos:
.minigit/HEAD.minigit/objects/.minigit/refs/heads/main
Si ejecutas init más de una vez, no pasa nada grave. La función
init_repo() usa create_dir_all(), así que puede crear carpetas
faltantes sin romper las que ya existen.
Además, el código solo escribe HEAD y refs/heads/main si esos
archivos todavía no existen. Eso evita sobrescribir información importante por accidente.
.minigit: recuerda que muchas veces
las carpetas que empiezan con punto se consideran ocultas. En algunos exploradores de
archivos debes activar la opción de mostrar archivos ocultos.
Con esta prueba confirmamos que MiniGit ya puede crear su repositorio interno. Esto es importante porque los siguientes comandos necesitan esa estructura para funcionar.
El comando hash podrá calcular un identificador sin guardar nada, pero
write, show, commit y log dependerán
de que .minigit exista.
init
↓
crea .minigit
↓
prepara objects/
↓
crea HEAD
↓
prepara refs/heads/main
Qué logramos con init
MiniGit ya tiene un lugar propio para guardar objetos y referencias. Todavía no hemos guardado contenido, pero la estructura interna del repositorio ya existe y puede ser inspeccionada desde el sistema de archivos.
En la siguiente parte vamos a probar el comando hash. Ahí veremos si
MiniGit puede tomar un archivo real, construir un objeto tipo blob y
producir el mismo hash que generaría Git para ese contenido.
15. Probar hash y comparar con Git
Ya comprobamos que init crea la carpeta interna .minigit.
Ahora vamos a probar una de las ideas centrales del tutorial: calcular el hash de un
archivo usando el mismo modelo mental de los objetos tipo blob.
La prueba será muy concreta. Crearemos un archivo llamado nota.txt con el
contenido exacto hola mundo, sin salto de línea final. Luego calcularemos
su hash con MiniGit y lo compararemos con el resultado de git hash-object.
Si ambos resultados coinciden, habremos demostrado que MiniGit está construyendo el
objeto tipo blob correctamente antes de calcular SHA-1.
echo.
Necesitamos controlar los bytes exactos del archivo. Si aparece un salto de línea
invisible al final, el hash cambiará.
Si estás en PowerShell, crea el archivo escribiendo directamente los bytes UTF-8. Esta forma evita que se agregue un salto de línea final por accidente.
[System.IO.File]::WriteAllBytes("nota.txt", [System.Text.Encoding]::UTF8.GetBytes("hola mundo"))
Si estás en Linux o macOS, puedes crear el mismo archivo usando printf.
A diferencia de otros comandos de terminal, aquí controlamos mejor que no aparezca un
salto de línea final.
printf "hola mundo" > nota.txt
El archivo debe contener exactamente estos bytes:
hola mundo
Ahora ejecutamos el comando hash de MiniGit. Este comando leerá
nota.txt, construirá internamente un objeto tipo blob y
calculará el SHA-1 sobre ese objeto completo.
cargo run -- hash nota.txt
La salida importante debe ser un hash de 40 caracteres hexadecimales. Si estás usando
exactamente hola mundo, sin salto de línea final, el resultado esperado es:
30364d155645e66aa59165e5df8e5f9ca8c6eecd
Ahora vamos a pedirle a Git real que calcule el hash del mismo archivo. Para esta prueba
no necesitamos hacer un repositorio con Git; el comando hash-object puede
calcular el identificador de un archivo directamente.
git hash-object nota.txt
Git debería imprimir el mismo resultado:
30364d155645e66aa59165e5df8e5f9ca8c6eecd
Resultado esperado
Si MiniGit y Git real imprimen el mismo hash, significa que estamos construyendo el
objeto tipo blob de forma compatible con la idea básica de Git.
El hash no se calculó sobre el texto visible solamente. Se calculó sobre el objeto completo: cabecera, separador nulo y contenido.
blob 10\0hola mundo
↓
SHA-1
↓
30364d155645e66aa59165e5df8e5f9ca8c6eecd
Esta comparación es una de las pruebas más importantes del tutorial. Hasta este punto, MiniGit no está guardando el objeto todavía; solo está calculando su identidad.
Eso nos permite separar dos ideas: primero podemos calcular el hash de un objeto, y
después podemos decidir si queremos guardarlo dentro de .minigit/objects.
nota.txt no tenga un salto de línea final, espacios
extra, caracteres invisibles o una codificación diferente.
hola mundo -> hash esperado
hola mundo\n -> otro hash diferente
Qué logramos con hash
MiniGit ya puede leer un archivo como bytes, construir un objeto tipo blob
y calcular un hash compatible con Git para ese contenido exacto. Todavía no hemos
guardado el objeto, pero ya sabemos cómo obtener su identidad.
En la siguiente parte vamos a dar el paso que falta: guardar ese objeto dentro de
.minigit/objects usando el hash como nombre de archivo, y luego recuperarlo
con el comando show.
16. Probar write y show
Ya logramos calcular un hash compatible con Git para el archivo nota.txt.
Pero hasta ahora MiniGit solo conoce la identidad del objeto; todavía no lo hemos
guardado dentro de .minigit/objects.
En esta parte vamos a cerrar el ciclo básico de objetos: primero guardaremos el objeto
usando el comando write, luego revisaremos que aparezca dentro de la carpeta
interna y finalmente recuperaremos su contenido usando show.
Esta prueba es importante porque convierte el hash en algo práctico. El hash ya no será solo un texto calculado en pantalla, sino la dirección real donde MiniGit guarda el objeto.
Para esta prueba vamos a reutilizar el mismo archivo nota.txt de la parte
anterior. Debe contener exactamente hola mundo, sin salto de línea final.
hola mundo
Ahora ejecutamos el comando write. Este comando hace dos cosas: construye
el objeto tipo blob, calcula su hash y lo guarda dentro de
.minigit/objects.
cargo run -- write nota.txt
Si el archivo tiene exactamente el mismo contenido que usamos antes, la salida esperada será esta:
Objeto guardado: 30364d155645e66aa59165e5df8e5f9ca8c6eecd
Ese hash ahora tiene una consecuencia física dentro del repositorio. MiniGit debe haber
creado un archivo dentro de .minigit/objects usando ese hash como nombre.
.minigit/objects/30364d155645e66aa59165e5df8e5f9ca8c6eecd
En PowerShell puedes inspeccionar la carpeta de objetos con este comando:
Get-ChildItem .minigit\objects
En Linux o macOS puedes hacer la misma revisión con:
ls .minigit/objects
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!