Build a Git-Inspired Rust VCS
Build a local Rust CLI that stores files, snapshots, and commit history.
Introduction
30 Second Summary
Saving files as final.txt, final-2.txt, or final-really-this-time.txt keeps old work nearby. The missing piece is a trustworthy trail from one version to the next.
In this project, you will build FerrumVCS, a local version control engine written in Rust. The finished CLI stores file versions as linked snapshots that you can inspect from HEAD.
What You'll Build
Your demo ends with notes.txt showing a newer working version while your own CLI recovers the earlier content stored at HEAD.
By the end of this project, you'll have:
- A local content-addressed object store that gives immutable file contents deterministic SHA-256 IDs.
- A flat staging index that keeps version one staged after notes.txt changes to version two.
- A commit history you can walk backward from HEAD. You can recover committed text while the working copy stays edited.
- Secret Mission: Add a checkout command that restores every root-level file from a selected commit.
Do I need experience with Git internals?
You should be comfortable running Rust CLIs with Rust 1.85 or later. No prior knowledge of Git internals is required.
Before We Start
Before you start building FerrumVCS, lock in the object flow you are about to recreate locally. This gives each file, snapshot, commit, and branch reference a clear role before you write any Rust.
Scaffold and Verify the Rust CLI
FerrumVCS relies on the sha2 crate for its object IDs. Version 0.11.0 needs Rust 1.85 or later. An older compiler would stop the build before your storage engine can run.
This step gives you a known-good baseline for every object-model feature that follows. You'll verify the compiler before creating the project.
Cargo will scaffold the binary. Visual Studio Code will hold the project files while you prove the starter target works.
In this step, get ready to:
- Confirm that Rust 1.85 or later is available.
- Scaffold the ferrum-vcs Cargo binary project.
- Pin the manifest and run the rvcs starter binary.
Check the Rust toolchain
The compiler version determines whether Cargo can build the pinned hashing dependency. Start by checking the toolchain already installed on your Linux machine.
- Check the installed Rust compiler version by running this command:
rustc --version
What does this command do?
The rustc command reports the compiler version currently available in your terminal. FerrumVCS needs version 1.85 or later because that is the minimum supported version for the pinned hashing dependency.
✔️ I see version 1.85 or higher
Your compiler meets the requirement. That's the first dependency cleared, so the project can use its pinned hashing crate.
ⓧ I see an older version
An older compiler needs an update before Cargo can build this project. Use the update path that matches how Rust was installed.
- Update a rustup-managed installation by running this command:
rustup update
What does this command do?
The command updates the toolchains managed by rustup. This brings an existing rustup installation up to date without changing your project files.
- Use the official Rust installation page if the rustup command is unavailable.
- Check the updated compiler version by running this command:
rustc --version
What should I see?
The command reports the active compiler version after the update. Confirm that the version is now 1.85 or later.
Still seeing an older version?
Your terminal may still be using a Rust installation that is outside rustup. Use the official installation page to update that installation.
Ask for help if the reported version does not change. Help me identify why my Linux terminal still reports an older Rust compiler after I ran rustup update.
Once the compiler reports version 1.85 or later, your toolchain is ready for the project.
ⓧ Command not found
Rust is unavailable in the current terminal. The official installer asks you to choose an installation option, so accept its default toolchain setup.
- Install Rust through rustup by running this command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
What does this command do?
This downloads the official rustup installer over HTTPS. The installer sets up the Rust compiler and Cargo on your Linux machine.
- Close the current terminal after the installer finishes.
- Open a new terminal from your Linux application menu.
- Confirm the installed compiler version by running this command:
rustc --version
What should I see?
The new terminal loads the installed Rust commands. Confirm that the reported compiler version is 1.85 or later.
Rust command still unavailable?
A terminal opened before installation may not know where the new commands live. Close every terminal window before opening a fresh one.
If the command remains unavailable, follow the official Rust installation page for your Linux environment.
Need help checking the installation? Help me troubleshoot why rustc is unavailable after installing Rust on Linux.
Create the Cargo project
A Cargo binary package gives FerrumVCS its manifest and starter source file. The scaffold also avoids creating unrelated version-control metadata.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
This moves the terminal into your Desktop directory. The project folder will be easy to find there.
- Create the ferrum-vcs binary project by running this command:
cargo new --bin --vcs none ferrum-vcs
What does this command do?
- The command creates a Cargo package named ferrum-vcs.
- The --bin option creates a runnable program with src/main.rs.
- The --vcs none option prevents Cargo from initializing another version-control system.
Cargo reports that it created a binary package. The new folder contains Cargo.toml and src/main.rs.
- Open the generated ferrum-vcs folder in Visual Studio Code by running these commands:
cd ferrum-vcs
code .
What do these commands do?
- The first command moves the terminal into the generated ferrum-vcs folder.
- The second command opens the current folder in Visual Studio Code.
You'll see Cargo.toml and the src folder in the Explorer. Your starter Rust project is now open in the editor.
Visual Studio Code did not open?
The Visual Studio Code command may be missing from your terminal path. Use your Linux application search to open Visual Studio Code.
Use its folder picker to select ~/Desktop/ferrum-vcs.
Need help opening the project? Help me open my ferrum-vcs folder in Visual Studio Code on Linux.
Pin the manifest and run rvcs
The manifest controls the package edition and dependency versions. It also gives the compiled binary the name rvcs while keeping src/main.rs as its source.
- Select Cargo.toml in the Explorer.
- Replace its contents with the following manifest:
[package]
name = "ferrum-vcs"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "rvcs"
path = "src/main.rs"
[dependencies]
sha2 = "=0.11.0"
What does this manifest configure?
- The package section identifies the project and selects Rust edition 2024.
- The binary section names the compiled command rvcs.
- The dependency entry pins sha2 to exactly 0.11.0.
- Save Cargo.toml.
Before you run the starter target, what message do you expect the generated program to print?
- Build the rvcs binary and run it by entering this command in the terminal:
cargo run
What does this command do?
Cargo fetches the pinned dependency before compiling the project. It then runs the binary target named rvcs.
After the build messages, you'll see Hello, world! in the terminal. That's the baseline proven: Cargo can build and run your rvcs binary.
Starter binary did not run?
Check that Cargo.toml matches the manifest below exactly. A missing quote or section header prevents Cargo from reading it.
If compilation mentions an unsupported compiler, return to the version check and confirm Rust 1.85 or later is active.
Still stuck? Help me troubleshoot why cargo run cannot build my ferrum-vcs starter project.
✔️ Awesome, I've got everything!
Great. Save both files and keep the successful starter output visible in your terminal.
ⓧ I'd like to double check the full code
Compare your manifest with this complete version.
[package]
name = "ferrum-vcs"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "rvcs"
path = "src/main.rs"
[dependencies]
sha2 = "=0.11.0"
Why this file matters
This is the complete manifest for the current project state. It pins the dependency and directs Cargo to build src/main.rs as rvcs.
Compare the generated starter source with this complete version.
fn main() {
println!("Hello, world!");
}
Why keep the starter simple?
This source is Cargo's starter program. Its successful output proves the toolchain and manifest work before FerrumVCS gains command dispatch or object storage.
Your Rust toolchain is ready, your project is open, and the rvcs binary runs. Next, you'll replace the starter with repository initialization and your first immutable blob object.
Initialize the Repository and Store a Blob
Your Rust CLI now builds through Cargo. A version control engine also needs a private home for its metadata before it can preserve history.
This step creates that repository structure. You will also build a content-addressed storage layer to test how far one stored file can take the model.
In this step, get ready to:
- Add shared command parsing to the CLI.
- Build typed SHA-256 object storage.
- Initialize the repository and store notes.txt as a blob.
Prepare shared error handling
Commands can fail because arguments are missing or files cannot be read. Result-based errors let each function pass those failures back to one entry point.
- In src/main.rs, place this code above the existing main() function:
use std::{env, error::Error, process};
pub type AppResult<T> = Result<T, Box<dyn Error>>;
fn next_arg<I>(args: &mut I, name: &str) -> AppResult<String>
where
I: Iterator<Item = String>,
{
match args.next() {
Some(value) => Ok(value),
None => Err(format!("missing {name}").into()),
}
}
What does this code do?
- The standard library imports provide command arguments, a shared error trait, and process control.
- The AppResult<T> alias gives every command the same success or failure shape.
- The next_arg() helper returns the next command argument or a useful missing-value error.
- Save src/main.rs.
- Confirm the starter binary still compiles by running:
cargo run
What does this command check?
Cargo compiles the updated entry point before running it. This catches syntax or type errors in the shared command plumbing.
You will still see Hello, world!. Compiler warnings about unused items are expected because command dispatch comes later in this step.
Seeing a compiler error?
- Check that the new code sits above the existing main() function.
- Compare every brace in next_arg() with the snippet.
Ask for help with the exact compiler output: Help me fix the shared Rust error-handling code in src/main.rs.
Build the typed object store
A blob stores file bytes as an immutable object. Its SHA-256 identifier comes from an encoded header plus the file data.
Why use typed bytes?
The header records the object kind and byte length. Including that header in the hash ties the identifier to the object's meaning as well as its contents.
- In the file sidebar of the Visual Studio Code window from Step 1, create src/object.rs.
- Add the object type, encoder, and identifier function by pasting:
use sha2::{Digest, Sha256};
use std::{fs, path::Path, str};
use crate::AppResult;
pub struct StoredObject {
pub kind: String,
pub data: Vec<u8>,
}
fn encode(kind: &str, data: &[u8]) -> Vec<u8> {
let mut encoded = format!("{kind} {}\0", data.len()).into_bytes();
encoded.extend_from_slice(data);
encoded
}
fn object_id(encoded: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(encoded);
let digest = hasher.finalize();
let mut id = String::with_capacity(64);
for byte in digest.iter() {
id.push_str(&format!("{:02x}", *byte));
}
id
}
How does object identity work?
- The StoredObject structure keeps the decoded object kind beside its data.
- The encode() function prefixes the data with its kind and byte length.
- The object_id() function hashes the complete encoding into a 64-character lowercase hexadecimal ID.
You can now see object.rs beneath the src folder. The next line makes Rust compile it as a project module.
- In src/main.rs, insert this line above the standard library import:
mod object;
What does this declaration do?
The declaration connects src/object.rs to the crate. Cargo now checks the object code during each build.
- Save your changes.
- Compile the new module by running:
cargo run
What does this compile prove?
Cargo now parses and type-checks the object module. The starter entry point still prints its original message.
You will see Hello, world! after the build completes. Warnings about unused object functions are expected until the CLI calls them.
Object module not compiling?
- Check that the file path is exactly src/object.rs.
- Check that mod object; is the first line of src/main.rs.
Ask for help with the module error: Help me diagnose why Rust cannot compile my object module.
The object store also needs a write boundary. It validates the future lookup format before placing each new encoding beneath its content ID.
- At the bottom of src/object.rs, add the validation and write functions by pasting:
fn validate_object_id(id: &str) -> AppResult<()> {
if id.len() != 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(format!("invalid object ID: {id}").into());
}
Ok(())
}
pub fn write_object(root: &Path, kind: &str, data: &[u8]) -> AppResult<String> {
let encoded = encode(kind, data);
let id = object_id(&encoded);
let path = root.join(".rvcs").join("objects").join(&id);
if !path.exists() {
fs::write(path, encoded)?;
}
Ok(id)
}
What does the write path protect?
- The validator accepts IDs with exactly 64 hexadecimal characters.
- The write_object() function derives the storage path from the encoded contents.
- The existence check preserves an object that is already stored under the same ID.
- Save src/object.rs.
- Compile the write path by running:
cargo run
What does this build check?
Cargo verifies that the hashing output can become a file path. It also checks that filesystem errors fit the shared result type.
The build completes before the starter message appears. This confirms that the write path type-checks.
Write path failing to compile?
- Check that Path and fs remain in the imports at the top of src/object.rs.
- Check that write_object() sits outside validate_object_id().
Ask for help with the write function: Help me fix the Rust compiler error in write_object.
Reading an object should verify the same identity that created its path. It should also reject malformed headers or mismatched sizes before returning data.
- At the bottom of src/object.rs, add the object reader by pasting:
pub fn read_object(root: &Path, id: &str) -> AppResult<StoredObject> {
validate_object_id(id)?;
let path = root.join(".rvcs").join("objects").join(id);
let encoded = fs::read(path)?;
if object_id(&encoded) != id {
return Err(format!("object integrity check failed: {id}").into());
}
let separator = encoded.iter().position(|byte| *byte == 0).ok_or_else(|| "object header has no separator".to_string())?;
let header = str::from_utf8(&encoded[..separator])?;
let (kind, size_text) = header.split_once(' ').ok_or_else(|| "object header is malformed".to_string())?;
let expected_size: usize = size_text.parse()?;
let data = encoded[separator + 1..].to_vec();
if data.len() != expected_size {
return Err("object size does not match its header".into());
}
Ok(StoredObject { kind: kind.to_string(), data })
}
How does the integrity check work?
- The reader validates the requested ID before using it as a path.
- It hashes the stored encoding again to detect changed bytes.
- It separates the header from the data at the null byte.
- It compares the decoded data length with the size recorded in the header.
- Save src/object.rs.
- Compile the complete object layer by running:
cargo run
What does this build confirm?
Cargo type-checks the complete encode, write, and read path. A successful build confirms that every object function fits the shared error model.
You will see the starter message after compilation. The object layer is now ready for repository commands.
Reader not compiling?
- Check that str remains in the standard library import.
- Check that the separator comparison uses the numeric value 0.
- Check that StoredObject still exposes both fields as public.
Ask for help with the reader: Help me diagnose the object decoding error in src/object.rs.
✔️ Awesome, I've got everything!
Great. Save src/object.rs before moving on.
ⓧ I'd like to double check the full code
use sha2::{Digest, Sha256};
use std::{fs, path::Path, str};
use crate::AppResult;
pub struct StoredObject {
pub kind: String,
pub data: Vec<u8>,
}
fn encode(kind: &str, data: &[u8]) -> Vec<u8> {
let mut encoded = format!("{kind} {}\0", data.len()).into_bytes();
encoded.extend_from_slice(data);
encoded
}
fn object_id(encoded: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(encoded);
let digest = hasher.finalize();
let mut id = String::with_capacity(64);
for byte in digest.iter() {
id.push_str(&format!("{:02x}", *byte));
}
id
}
fn validate_object_id(id: &str) -> AppResult<()> {
if id.len() != 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(format!("invalid object ID: {id}").into());
}
Ok(())
}
pub fn write_object(root: &Path, kind: &str, data: &[u8]) -> AppResult<String> {
let encoded = encode(kind, data);
let id = object_id(&encoded);
let path = root.join(".rvcs").join("objects").join(&id);
if !path.exists() {
fs::write(path, encoded)?;
}
Ok(id)
}
pub fn read_object(root: &Path, id: &str) -> AppResult<StoredObject> {
validate_object_id(id)?;
let path = root.join(".rvcs").join("objects").join(id);
let encoded = fs::read(path)?;
if object_id(&encoded) != id {
return Err(format!("object integrity check failed: {id}").into());
}
let separator = encoded.iter().position(|byte| *byte == 0).ok_or_else(|| "object header has no separator".to_string())?;
let header = str::from_utf8(&encoded[..separator])?;
let (kind, size_text) = header.split_once(' ').ok_or_else(|| "object header is malformed".to_string())?;
let expected_size: usize = size_text.parse()?;
let data = encoded[separator + 1..].to_vec();
if data.len() != expected_size {
return Err("object size does not match its header".into());
}
Ok(StoredObject { kind: kind.to_string(), data })
}
What should match?
Your file should contain the encoding functions before the write and read paths. Each identifier should match this reference exactly.
Initialize the repository and store a blob
The repository layer gives the object store a predictable directory structure. It also restricts this learning engine to files at the top level of ferrum-vcs.
- In the file sidebar, create src/repository.rs.
- Add the repository path helpers by pasting:
use std::{env, fs, path::{Component, Path, PathBuf}};
use crate::{object, AppResult};
const RVCS_DIR: &str = ".rvcs";
fn root() -> AppResult<PathBuf> {
Ok(env::current_dir()?)
}
fn ensure_repo(root: &Path) -> AppResult<()> {
if !root.join(RVCS_DIR).join("HEAD").is_file() {
return Err("not an rvcs repository; run `rvcs init` first".into());
}
Ok(())
}
fn validate_root_file(path_text: &str) -> AppResult<&Path> {
let path = Path::new(path_text);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(path),
_ => Err("this learning version accepts root-level files only".into()),
}
}
What do these helpers establish?
- The root() helper treats the current terminal directory as the repository root.
- The ensure_repo() helper uses .rvcs/HEAD as the repository marker.
- The validate_root_file() helper accepts one normal path component.
You can now see repository.rs beneath src. The module declaration makes Cargo check these helpers.
- In src/main.rs, insert this line directly below mod object;:
mod repository;
What does this declaration connect?
The declaration adds src/repository.rs to the crate. Its functions can now call the shared object layer.
- Save your changes.
- Compile the repository helpers by running:
cargo run
What does this compile prove?
Cargo checks the module link from the CLI to the repository layer. It also verifies the repository layer's access to the object module.
You will see Hello, world! after compilation. Warnings remain expected until dispatch is connected.
Repository module not compiling?
- Check that the file path is exactly src/repository.rs.
- Check that mod object; appears before mod repository;.
Ask for help with the module link: Help me diagnose why repository.rs cannot access the object module.
Initialization creates the private repository layout. The symbolic HEAD records which branch reference future commits should advance.
- At the bottom of src/repository.rs, add repository initialization by pasting:
pub fn init() -> AppResult<()> {
let root = root()?;
let repo = root.join(RVCS_DIR);
if repo.exists() {
return Err(".rvcs already exists".into());
}
fs::create_dir_all(repo.join("objects"))?;
fs::create_dir_all(repo.join("refs").join("heads"))?;
fs::write(repo.join("HEAD"), "ref: refs/heads/main\n")?;
fs::write(repo.join("index"), "")?;
println!("Initialized empty rvcs repository in {}", repo.display());
Ok(())
}
What does initialization create?
- The objects directory holds immutable encoded objects.
- The refs/heads directory reserves a location for branch tips.
- The HEAD file contains the symbolic reference ref: refs/heads/main.
- The empty index file reserves the staging area used in the next step.
- Save src/repository.rs.
- Compile the initialization function by running:
cargo run
What does this build check?
Cargo verifies every directory and file operation in init(). The command is only compiled at this point.
The starter message still appears. Actual repository creation begins after command dispatch is connected.
Initialization function not compiling?
- Check that fs remains in the repository imports.
- Check that the symbolic reference string ends with \n.
Ask for help with initialization: Help me fix the Rust compiler error in repository init.
The hash command bridges a working file to the object database. It reads the file once before passing those bytes to write_object().
- At the bottom of src/repository.rs, add the blob-writing command by pasting:
pub fn hash_object(path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let path = validate_root_file(path_text)?;
let data = fs::read(path)?;
let id = object::write_object(&root, "blob", &data)?;
println!("{id}");
Ok(())
}
How does a file become a blob?
- The command confirms that the current folder contains an rvcs repository.
- It rejects nested or special paths before reading data.
- It stores the bytes with the object kind blob.
- It prints the resulting object ID for inspection.
- Save src/repository.rs.
- Compile the complete repository layer by running:
cargo run
What does this build confirm?
Cargo checks the path validation and blob-writing flow together. A successful build means the repository can call the object store.
You will see the starter message after compilation. Command dispatch is the final connection.
Blob command not compiling?
- Check that the object kind is the string blob.
- Check that write_object() receives a reference to root.
Ask for help with the command: Help me diagnose the hash_object compiler error.
✔️ Awesome, I've got everything!
Your repository module is ready. Save src/repository.rs before wiring the commands.
ⓧ I'd like to double check the full code
use std::{env, fs, path::{Component, Path, PathBuf}};
use crate::{object, AppResult};
const RVCS_DIR: &str = ".rvcs";
fn root() -> AppResult<PathBuf> {
Ok(env::current_dir()?)
}
fn ensure_repo(root: &Path) -> AppResult<()> {
if !root.join(RVCS_DIR).join("HEAD").is_file() {
return Err("not an rvcs repository; run `rvcs init` first".into());
}
Ok(())
}
fn validate_root_file(path_text: &str) -> AppResult<&Path> {
let path = Path::new(path_text);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(path),
_ => Err("this learning version accepts root-level files only".into()),
}
}
pub fn init() -> AppResult<()> {
let root = root()?;
let repo = root.join(RVCS_DIR);
if repo.exists() {
return Err(".rvcs already exists".into());
}
fs::create_dir_all(repo.join("objects"))?;
fs::create_dir_all(repo.join("refs").join("heads"))?;
fs::write(repo.join("HEAD"), "ref: refs/heads/main\n")?;
fs::write(repo.join("index"), "")?;
println!("Initialized empty rvcs repository in {}", repo.display());
Ok(())
}
pub fn hash_object(path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let path = validate_root_file(path_text)?;
let data = fs::read(path)?;
let id = object::write_object(&root, "blob", &data)?;
println!("{id}");
Ok(())
}
What should match?
Your module should contain the path helpers before init() and hash_object(). The repository paths should match this reference exactly.
The entry point still runs the starter program. Replacing that function lets rvcs route command-line arguments into the repository layer.
- In src/main.rs, find the starter function shown below:
fn main() {
println!("Hello, world!");
}
What is being replaced?
This starter function always prints one fixed message. It does not inspect command-line arguments.
- Replace the starter function with this command dispatcher:
fn run() -> AppResult<()> {
let mut args = env::args().skip(1);
let command = next_arg(&mut args, "command")?;
match command.as_str() {
"init" => repository::init(),
"hash-object" => {
let path = next_arg(&mut args, "file")?;
repository::hash_object(&path)
}
_ => Err(format!("unknown command: {command}").into()),
}
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
process::exit(1);
}
}
How does dispatch work?
- The run() function skips the executable path before reading the command.
- The init branch calls repository initialization.
- The hash-object branch requires one file argument.
- The main() function prints propagated errors before exiting with a failure status.
- Save src/main.rs.
✔️ Awesome, I've got everything!
The CLI now recognizes init and hash-object commands.
ⓧ I'd like to double check the full code
mod object;
mod repository;
use std::{env, error::Error, process};
pub type AppResult<T> = Result<T, Box<dyn Error>>;
fn next_arg<I>(args: &mut I, name: &str) -> AppResult<String>
where
I: Iterator<Item = String>,
{
match args.next() {
Some(value) => Ok(value),
None => Err(format!("missing {name}").into()),
}
}
fn run() -> AppResult<()> {
let mut args = env::args().skip(1);
let command = next_arg(&mut args, "command")?;
match command.as_str() {
"init" => repository::init(),
"hash-object" => {
let path = next_arg(&mut args, "file")?;
repository::hash_object(&path)
}
_ => Err(format!("unknown command: {command}").into()),
}
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
process::exit(1);
}
}
What should match?
Your entry point should declare both modules before the imports. The dispatch branches should call the matching repository functions.
- Initialize the local repository by running:
cargo run -- init
What does this command do?
Cargo passes the argument after -- to the rvcs binary. The binary creates the .rvcs repository structure in ferrum-vcs.
You will see a message beginning with Initialized empty rvcs repository in. That output confirms that the private repository home now exists.
Repository did not initialize?
- Check that the terminal is inside the ferrum-vcs folder.
- Check the file sidebar for an existing .rvcs directory if the command reports that one already exists.
Ask for help with initialization: Help me diagnose why rvcs init did not create the repository.
- In the file sidebar, create notes.txt inside the top-level ferrum-vcs folder.
You will see notes.txt beside Cargo.toml in the project file list. This confirms that the file is at the supported root level.
- Enter version one in notes.txt.
- Save notes.txt.
Before you run the hash command, predict whether its output can reveal the source filename.
- Store the file as a blob by running:
cargo run -- hash-object notes.txt
What does this command store?
The command reads notes.txt before encoding its bytes as a typed blob. The object layer writes that encoding beneath its SHA-256 ID.
You will see a 64-character lowercase object ID. A file with that exact name now exists under .rvcs/objects.
- Expand .rvcs in the file sidebar.
- Expand objects to locate the file whose name matches the printed ID.
- Open .rvcs/HEAD to confirm it contains ref: refs/heads/main.
- Open .rvcs/index to confirm it is empty.
The blob ID identifies the typed contents. It contains no relationship to the name notes.txt.
Why does the filename disappear?
The object layer hashes the blob header and file bytes. The path is not part of that input.
This is the intended shortfall. A separate mapping must preserve which filename points to which blob.
Blob object missing?
- Check that notes.txt sits beside Cargo.toml.
- Check that cargo run -- init succeeded before the hash command.
- Compare the printed ID with the filename inside .rvcs/objects.
Ask for help with the object write: Help me diagnose why hash-object did not create the blob file.
That is your first working object flow. Next, you will add the missing filename-to-blob mapping with a flat staging index.
Add a Flat Staging Index
The blob you stored preserves the exact bytes from notes.txt. Its 64-character ID carries no relationship to the filename.
A flat staging index solves that gap by mapping each root-level path to one blob ID. You will preserve version one in the index after the working file changes to version two.
In this step, get ready to:
- Parse index entries into a deterministic path-to-object map.
- Add the command that stages the current contents of a root-level file.
- Edit the working file while preserving its earlier staged version.
Parse and save the flat index
The index stores one blob ID and path on each line. A BTreeMap<String, String> keeps entries sorted by path when FerrumVCS writes them back to disk.
- In src/repository.rs, replace the existing use std import with this expanded import:
use std::{
collections::BTreeMap,
env, fs,
path::{Component, Path, PathBuf},
};
What does this import do?
The new import makes BTreeMap available to the repository module. Its key-ordered iteration gives the index a deterministic file order.
- Save src/repository.rs.
- In Visual Studio Code, select Problems in the bottom panel.
You should see no Rust errors from the expanded import.
Seeing an import error?
Check that collections::BTreeMap sits inside the braces of the existing standard library import. Confirm that the import ends with a single closing brace followed by a semicolon.
Ask for help with the import error.
- In src/repository.rs, add load_index() immediately after validate_root_file() by pasting this code:
fn load_index(root: &Path) -> AppResult<BTreeMap<String, String>> {
let text = fs::read_to_string(root.join(RVCS_DIR).join("index"))?;
let mut entries = BTreeMap::new();
for line in text.lines().filter(|line| !line.is_empty()) {
let (id, path) = line
.split_once('\t')
.ok_or_else(|| "index entry is malformed".to_string())?;
entries.insert(path.to_string(), id.to_string());
}
Ok(entries)
}
How does index loading work?
- The function reads the complete .rvcs/index file as text.
- Each non-empty line is split at its tab character.
- The path becomes the map key. The blob ID becomes its value.
- A malformed line returns an error through AppResult.
- Save src/repository.rs.
- Return to the Problems panel.
You should still see no Rust errors. A warning about an unused function is safe at this point because the staging command uses it shortly.
Seeing a syntax error in load_index?
Check that load_index() sits outside validate_root_file(). Make sure the earlier function has its own closing brace.
Ask for help with the parser error.
- In src/repository.rs, add save_index() immediately after load_index() by pasting this code:
fn save_index(root: &Path, entries: &BTreeMap<String, String>) -> AppResult<()> {
let mut text = String::new();
for (path, id) in entries {
text.push_str(&format!("{id}\t{path}\n"));
}
fs::write(root.join(RVCS_DIR).join("index"), text)?;
Ok(())
}
How does index saving work?
- The loop receives entries in path order from BTreeMap.
- Each line contains the blob ID. A tab separates it from the path.
- The completed text replaces the contents of .rvcs/index.
- Save src/repository.rs.
- Return to the Problems panel.
You should see no Rust errors from either index helper.
Seeing an error in save_index?
Confirm that the map parameter is borrowed with &BTreeMap<String, String>. Check that the formatted entry contains the ID before the path.
Ask for help with index serialization.
Stage the current file version
Staging must capture the file bytes that exist at the moment you run the command. The resulting blob ID is saved beside the path without rereading that file during a future commit.
- In src/repository.rs, add add() immediately after hash_object() by pasting this code:
pub fn add(path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let path = validate_root_file(path_text)?;
let data = fs::read(path)?;
let id = object::write_object(&root, "blob", &data)?;
let mut entries = load_index(&root)?;
entries.insert(path_text.to_string(), id.clone());
save_index(&root, &entries)?;
println!("staged {path_text} {id}");
Ok(())
}
What does add do?
- The function validates that the supplied path identifies one root-level file.
- It reads the current bytes before storing or reusing their blob object.
- It loads the current index before inserting the path-to-object mapping.
- It saves the sorted map before printing the staged path and object ID.
- Save src/repository.rs.
- Return to the Problems panel.
You should see no Rust errors in the repository module.
Seeing an error in add?
Check that load_index() and save_index() appear before add() in the same module. Confirm that id.clone() preserves the ID for the final status message.
Ask for help with the staging function.
- In src/main.rs, find the hash-object match arm.
- Add the new add match arm directly below it by pasting this code:
"add" => {
let path = next_arg(&mut args, "file")?;
repository::add(&path)
}
How does the command reach the repository?
The match arm reads one file argument through next_arg(). It passes that path to repository::add() so the repository module can stage it.
- Save src/main.rs.
Before you run the command, do you expect the object ID to change when the file still contains the same version one bytes?
- Stage the current notes.txt bytes by running this command:
cargo run -- add notes.txt
What does this command do?
Cargo runs the rvcs binary with add and notes.txt as its arguments. FerrumVCS stores the current bytes before writing their object ID into the index.
You should see a line beginning with staged notes.txt followed by a 64-character object ID. The ID matches the earlier blob because the typed bytes are unchanged.
Command failed to stage the file?
Confirm that your terminal remains inside the ferrum-vcs folder. Check that notes.txt still exists at the top level beside Cargo.toml.
Ask for help with the staging command.
✔️ Awesome, I've got everything!
Your index helpers and staging command are working. The terminal now shows the blob ID saved for notes.txt.
ⓧ I'd like to double check the full code
Compare your complete src/repository.rs file with this version:
use std::{
collections::BTreeMap,
env, fs,
path::{Component, Path, PathBuf},
};
use crate::{object, AppResult};
const RVCS_DIR: &str = ".rvcs";
fn root() -> AppResult<PathBuf> {
Ok(env::current_dir()?)
}
fn ensure_repo(root: &Path) -> AppResult<()> {
if !root.join(RVCS_DIR).join("HEAD").is_file() {
return Err("not an rvcs repository; run `rvcs init` first".into());
}
Ok(())
}
fn validate_root_file(path_text: &str) -> AppResult<&Path> {
let path = Path::new(path_text);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(path),
_ => Err("this learning version accepts root-level files only".into()),
}
}
fn load_index(root: &Path) -> AppResult<BTreeMap<String, String>> {
let text = fs::read_to_string(root.join(RVCS_DIR).join("index"))?;
let mut entries = BTreeMap::new();
for line in text.lines().filter(|line| !line.is_empty()) {
let (id, path) = line
.split_once('\t')
.ok_or_else(|| "index entry is malformed".to_string())?;
entries.insert(path.to_string(), id.to_string());
}
Ok(entries)
}
fn save_index(root: &Path, entries: &BTreeMap<String, String>) -> AppResult<()> {
let mut text = String::new();
for (path, id) in entries {
text.push_str(&format!("{id}\t{path}\n"));
}
fs::write(root.join(RVCS_DIR).join("index"), text)?;
Ok(())
}
pub fn init() -> AppResult<()> {
let root = root()?;
let repo = root.join(RVCS_DIR);
if repo.exists() {
return Err(".rvcs already exists".into());
}
fs::create_dir_all(repo.join("objects"))?;
fs::create_dir_all(repo.join("refs").join("heads"))?;
fs::write(repo.join("HEAD"), "ref: refs/heads/main\n")?;
fs::write(repo.join("index"), "")?;
println!("Initialized empty rvcs repository in {}", repo.display());
Ok(())
}
pub fn hash_object(path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let path = validate_root_file(path_text)?;
let data = fs::read(path)?;
let id = object::write_object(&root, "blob", &data)?;
println!("{id}");
Ok(())
}
pub fn add(path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let path = validate_root_file(path_text)?;
let data = fs::read(path)?;
let id = object::write_object(&root, "blob", &data)?;
let mut entries = load_index(&root)?;
entries.insert(path_text.to_string(), id.clone());
save_index(&root, &entries)?;
println!("staged {path_text} {id}");
Ok(())
}
Compare your complete src/main.rs file with this version:
mod object;
mod repository;
use std::{env, error::Error, process};
pub type AppResult<T> = Result<T, Box<dyn Error>>;
fn next_arg<I>(args: &mut I, name: &str) -> AppResult<String>
where
I: Iterator<Item = String>,
{
match args.next() {
Some(value) => Ok(value),
None => Err(format!("missing {name}").into()),
}
}
fn run() -> AppResult<()> {
let mut args = env::args().skip(1);
let command = next_arg(&mut args, "command")?;
match command.as_str() {
"init" => repository::init(),
"hash-object" => {
let path = next_arg(&mut args, "file")?;
repository::hash_object(&path)
}
"add" => {
let path = next_arg(&mut args, "file")?;
repository::add(&path)
}
_ => Err(format!("unknown command: {command}").into()),
}
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
process::exit(1);
}
}
Prove working edits stay unstaged
The index now points to the blob created from version one. Editing the working file leaves that saved mapping untouched until you run the staging command again.
- In the Explorer sidebar, select notes.txt.
- Replace version one with version two.
- Save notes.txt.
Before you inspect the index, do you expect its blob ID to change when you save the working file without running add again?
- In the Explorer sidebar, expand the .rvcs folder.
- Select the index file.
You should see the same 64-character blob ID printed by the staging command. A tab separates that ID from notes.txt.
- Switch back to notes.txt.
You should see version two in the working file. The index still points to the blob containing version one.
Why does the index keep version one?
The index stores a blob ID captured by the last staging command. Saving a working file updates its bytes on disk without changing that stored mapping.
This separation lets a future commit freeze the staged snapshot while newer edits remain in your working directory.
You have built the boundary between working content and staged content. Next, you will freeze that staged mapping into an immutable tree and commit.
Create Commit History
Your flat staging index already preserves version one of notes.txt while the working file contains version two. The index still represents one mutable snapshot, so replacing its entries would erase any sense of history.
This step freezes the index as an immutable tree object. A commit object points to that tree. The current branch then moves to the new commit.
In this step, get ready to:
- Resolve the symbolic HEAD reference to the current branch.
- Store the sorted index as a tree inside a commit.
- Advance the main branch to the new commit ID.
Link commits to the current branch
A symbolic reference lets HEAD name the current branch without storing a commit ID itself. FerrumVCS accepts only refs/heads/main so every branch update has one validated destination.
- Return to src/repository.rs in Visual Studio Code.
- Find the const RVCS_DIR: &str = ".rvcs"; line.
- Add the commit data structure directly below that line:
struct Commit {
tree: String,
parent: Option<String>,
message: String,
}
What does this structure hold?
- The tree field identifies the immutable snapshot stored by the commit.
- The parent field links to the previous commit when one exists.
- The message field records the description supplied through the CLI.
- Find the closing brace of validate_root_file().
- Add the symbolic reference resolver directly below it:
fn head_ref(root: &Path) -> AppResult<String> {
let head = fs::read_to_string(root.join(RVCS_DIR).join("HEAD"))?;
let reference = head
.trim()
.strip_prefix("ref: ")
.ok_or_else(|| "HEAD is not a symbolic reference".to_string())?;
if reference != "refs/heads/main" {
return Err(format!("unsupported HEAD reference: {reference}").into());
}
Ok(reference.to_string())
}
How is HEAD resolved?
- The function reads .rvcs/HEAD from the current repository.
- The ref: prefix identifies the file content as a symbolic reference.
- The branch check prevents the engine from writing through an unsupported reference.
- Save src/repository.rs.
- Check the new structure and resolver for compilation errors by running:
cargo check
What does this command do?
The cargo check command verifies that the package compiles without producing the final executable. This gives you a fast checkpoint before adding the remaining reference helpers.
You should see Cargo finish the check successfully. A warning about unused code is expected because the commit command does not call these additions yet.
Seeing a compilation error?
Confirm that Commit sits outside every function. Check that head_ref() appears after the complete validate_root_file() function.
Compare the braces around both additions before continuing. Help me find the syntax error in my Commit structure or head_ref function.
- Find the closing brace of head_ref().
- Add the branch-tip reader directly below it:
fn head_commit(root: &Path) -> AppResult<Option<String>> {
let reference = head_ref(root)?;
let path = root.join(RVCS_DIR).join(reference);
if !path.exists() {
return Ok(None);
}
let id = fs::read_to_string(path)?.trim().to_string();
if id.is_empty() {
Ok(None)
} else {
Ok(Some(id))
}
}
How is the branch tip read?
- The function turns the validated reference into the path .rvcs/refs/heads/main.
- A missing or empty branch file produces None. That value represents the first commit having no parent.
- An existing commit ID becomes Some(id) for the next commit's parent link.
- Find the closing brace of head_commit().
- Add the branch update helper directly below it:
fn update_head(root: &Path, id: &str) -> AppResult<()> {
let reference = head_ref(root)?;
fs::write(root.join(RVCS_DIR).join(reference), format!("{id}\n"))?;
Ok(())
}
How does the branch move?
The helper resolves HEAD through the same validation path. It writes the new commit ID plus a newline to the current branch reference.
- Save src/repository.rs.
- Check the completed reference helpers by running:
cargo check
What does this check prove?
This check confirms that head_ref(), head_commit(), and update_head() agree on their path and result types. The next function can now use them to create history.
Cargo should finish successfully again. Warnings about unused helpers remain expected until the CLI dispatch reaches the commit function.
Reference helpers not compiling?
Check that head_commit() returns AppResult<Option<String>>. Confirm that update_head() receives id as a string slice.
Make sure both helpers remain above load_index(). Help me debug my head_commit and update_head helpers.
Freeze the staged snapshot
The index is already sorted by BTreeMap. Serializing those entries produces deterministic tree bytes, so the same staged snapshot produces the same tree ID.
The commit stores that tree ID plus an optional parent. Updating the branch only happens after both immutable objects have been stored successfully.
- Find the closing brace of add() near the bottom of src/repository.rs.
- Add the commit function directly below it:
pub fn commit(message: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
if message.contains('\n') || message.contains('\r') {
return Err("commit message must fit on one line".into());
}
let entries = load_index(&root)?;
if entries.is_empty() {
return Err("nothing is staged".into());
}
let mut tree_text = String::new();
for (path, id) in &entries {
tree_text.push_str(&format!("{id}\t{path}\n"));
}
let tree_id = object::write_object(&root, "tree", tree_text.as_bytes())?;
let mut commit_text = format!("tree {tree_id}\n");
if let Some(parent) = head_commit(&root)? {
commit_text.push_str(&format!("parent {parent}\n"));
}
commit_text.push_str(&format!("message {message}\n"));
let commit_id = object::write_object(&root, "commit", commit_text.as_bytes())?;
update_head(&root, &commit_id)?;
println!("committed {commit_id}");
Ok(())
}
How is history created?
- The message check keeps each serialized field on one line.
- The empty-index check prevents commits without a staged snapshot.
- The tree loop serializes each staged blob ID with its root-level path.
- The commit includes a parent line only when the branch already points to an earlier commit.
- The final update moves refs/heads/main after the tree and commit objects are safely stored.
- Save src/repository.rs.
- Check the complete repository module by running:
cargo check
What does this check cover?
Cargo now checks the tree serialization, optional parent handling, object writes, and branch update as one compiled flow. A successful check means the repository side of the commit command is ready.
You should see the check finish successfully. The public commit function may remain unused until you connect it to src/main.rs.
Commit function not compiling?
Confirm that the tree loop borrows entries with &entries. Check that both object writes pass byte slices through as_bytes().
Make sure update_head() receives a borrowed commit ID. Help me debug the commit function in my repository module.
✔️ Awesome, I've got everything!
Great. Save src/repository.rs before connecting the new function to the CLI.
ⓧ I'd like to double check the full code
use std::{
collections::BTreeMap,
env, fs,
path::{Component, Path, PathBuf},
};
use crate::{object, AppResult};
const RVCS_DIR: &str = ".rvcs";
struct Commit {
tree: String,
parent: Option<String>,
message: String,
}
fn root() -> AppResult<PathBuf> {
Ok(env::current_dir()?)
}
fn ensure_repo(root: &Path) -> AppResult<()> {
if !root.join(RVCS_DIR).join("HEAD").is_file() {
return Err("not an rvcs repository; run `rvcs init` first".into());
}
Ok(())
}
fn validate_root_file(path_text: &str) -> AppResult<&Path> {
let path = Path::new(path_text);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(path),
_ => Err("this learning version accepts root-level files only".into()),
}
}
fn head_ref(root: &Path) -> AppResult<String> {
let head = fs::read_to_string(root.join(RVCS_DIR).join("HEAD"))?;
let reference = head
.trim()
.strip_prefix("ref: ")
.ok_or_else(|| "HEAD is not a symbolic reference".to_string())?;
if reference != "refs/heads/main" {
return Err(format!("unsupported HEAD reference: {reference}").into());
}
Ok(reference.to_string())
}
fn head_commit(root: &Path) -> AppResult<Option<String>> {
let reference = head_ref(root)?;
let path = root.join(RVCS_DIR).join(reference);
if !path.exists() {
return Ok(None);
}
let id = fs::read_to_string(path)?.trim().to_string();
if id.is_empty() {
Ok(None)
} else {
Ok(Some(id))
}
}
fn update_head(root: &Path, id: &str) -> AppResult<()> {
let reference = head_ref(root)?;
fs::write(root.join(RVCS_DIR).join(reference), format!("{id}\n"))?;
Ok(())
}
fn load_index(root: &Path) -> AppResult<BTreeMap<String, String>> {
let text = fs::read_to_string(root.join(RVCS_DIR).join("index"))?;
let mut entries = BTreeMap::new();
for line in text.lines().filter(|line| !line.is_empty()) {
let (id, path) = line
.split_once('\t')
.ok_or_else(|| "index entry is malformed".to_string())?;
entries.insert(path.to_string(), id.to_string());
}
Ok(entries)
}
fn save_index(root: &Path, entries: &BTreeMap<String, String>) -> AppResult<()> {
let mut text = String::new();
for (path, id) in entries {
text.push_str(&format!("{id}\t{path}\n"));
}
fs::write(root.join(RVCS_DIR).join("index"), text)?;
Ok(())
}
pub fn init() -> AppResult<()> {
let root = root()?;
let repo = root.join(RVCS_DIR);
if repo.exists() {
return Err(".rvcs already exists".into());
}
fs::create_dir_all(repo.join("objects"))?;
fs::create_dir_all(repo.join("refs").join("heads"))?;
fs::write(repo.join("HEAD"), "ref: refs/heads/main\n")?;
fs::write(repo.join("index"), "")?;
println!("Initialized empty rvcs repository in {}", repo.display());
Ok(())
}
pub fn hash_object(path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let path = validate_root_file(path_text)?;
let data = fs::read(path)?;
let id = object::write_object(&root, "blob", &data)?;
println!("{id}");
Ok(())
}
pub fn add(path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let path = validate_root_file(path_text)?;
let data = fs::read(path)?;
let id = object::write_object(&root, "blob", &data)?;
let mut entries = load_index(&root)?;
entries.insert(path_text.to_string(), id.clone());
save_index(&root, &entries)?;
println!("staged {path_text} {id}");
Ok(())
}
pub fn commit(message: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
if message.contains('\n') || message.contains('\r') {
return Err("commit message must fit on one line".into());
}
let entries = load_index(&root)?;
if entries.is_empty() {
return Err("nothing is staged".into());
}
let mut tree_text = String::new();
for (path, id) in &entries {
tree_text.push_str(&format!("{id}\t{path}\n"));
}
let tree_id = object::write_object(&root, "tree", tree_text.as_bytes())?;
let mut commit_text = format!("tree {tree_id}\n");
if let Some(parent) = head_commit(&root)? {
commit_text.push_str(&format!("parent {parent}\n"));
}
commit_text.push_str(&format!("message {message}\n"));
let commit_id = object::write_object(&root, "commit", commit_text.as_bytes())?;
update_head(&root, &commit_id)?;
println!("committed {commit_id}");
Ok(())
}
What should you compare?
Check the placement of Commit and the three reference helpers first. Confirm that commit() appears after add().
Dispatch and verify the commit
The repository can now create history, but the CLI still needs a route to that function. The command gathers every remaining argument into one commit message.
- Switch back to src/main.rs.
- Find the closing brace of the add match arm inside run().
- Add the commit match arm directly below it:
"commit" => {
let message = args.collect::<Vec<_>>().join(" ");
if message.is_empty() {
return Err("missing commit message".into());
}
repository::commit(&message)
}
How is the message dispatched?
- The command collects all remaining arguments so a message can contain spaces.
- An empty message stops before any tree or commit object is written.
- A valid message is passed to repository::commit() for storage.
- Find the default error arm at the bottom of the same match block.
- Replace that arm with the Complete Code version:
_ => Err(format!(
"unknown command: {command}\nusage: rvcs <init|hash-object|add|commit|log|show>"
)
.into()),
Why update the usage text?
The default arm reports the accepted command names when dispatch cannot find a match. The list now includes commit alongside the commands already available.
- Save src/main.rs.
✔️ Awesome, I've got everything!
Your CLI now routes a non-empty message to repository::commit().
ⓧ I'd like to double check the full code
mod object;
mod repository;
use std::{env, error::Error, process};
pub type AppResult<T> = Result<T, Box<dyn Error>>;
fn next_arg<I>(args: &mut I, name: &str) -> AppResult<String>
where
I: Iterator<Item = String>,
{
match args.next() {
Some(value) => Ok(value),
None => Err(format!("missing {name}").into()),
}
}
fn run() -> AppResult<()> {
let mut args = env::args().skip(1);
let command = next_arg(&mut args, "command")?;
match command.as_str() {
"init" => repository::init(),
"hash-object" => {
let path = next_arg(&mut args, "file")?;
repository::hash_object(&path)
}
"add" => {
let path = next_arg(&mut args, "file")?;
repository::add(&path)
}
"commit" => {
let message = args.collect::<Vec<_>>().join(" ");
if message.is_empty() {
return Err("missing commit message".into());
}
repository::commit(&message)
}
_ => Err(format!(
"unknown command: {command}\nusage: rvcs <init|hash-object|add|commit|log|show>"
)
.into()),
}
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
process::exit(1);
}
}
What should you compare?
Confirm that the new commit arm follows the add arm. Check that main() remains unchanged.
Before you run this, consider whether the missing branch file will remain absent or gain a commit ID.
- Create the first snapshot by running:
cargo run -- commit first snapshot
What does this command trigger?
Cargo runs the rvcs binary with commit as its command. FerrumVCS turns the staged index into a tree, wraps that tree in the first commit, and advances the main branch.
You should see committed followed by a 64-character lowercase object ID. The first commit contains no parent because the branch had no earlier tip.
- Record the printed commit ID here: your first commit ID.
- In the Visual Studio Code file sidebar, expand .rvcs.
- Expand refs.
- Expand heads.
- Select main.
- Compare its value with your first commit ID.
You should see the same commit ID followed by a newline. This proves that refs/heads/main now points to the commit object.
- Select notes.txt in the file sidebar.
You should still see version two in the working file. Creating the commit did not rewrite your working copy.
- Return to .rvcs/index in the file sidebar.
The index should still map notes.txt to the version one blob ID. The commit froze the staged snapshot without rereading version two.
That is the key history jump complete: your branch now points to an immutable commit built from the staged snapshot.
Commit command not working?
Confirm that .rvcs/index still contains the staged notes.txt entry. Check that the commit match arm calls repository::commit(&message).
If the branch file is missing after a successful run, compare head_ref() and update_head() with the full repository code. Help me debug why my first FerrumVCS commit is not updating the main branch.
Your repository now has a branch tip plus an immutable tree and commit behind it. Next, you will follow those object links to display history and recover the staged version of notes.txt.
Walk and Read the Object Graph
Your first commit now freezes the staged version of notes.txt inside immutable tree and commit objects. The symbolic HEAD reference points to that history through the main branch.
Those objects become useful when your CLI can traverse the object graph. In this step, you will follow commit parent links and recover the staged snapshot without reading the newer working copy.
In this step, get ready to:
- Parse commit objects and walk their parent links from HEAD.
- Resolve a revision through its commit and tree objects.
- Recover the committed contents of notes.txt from its blob object.
Parse commits and walk history
A history entry must have the expected object type before the CLI interprets its bytes as a commit. The parsed parent field gives the traversal its next object ID.
- In src/repository.rs, find the closing brace of save_index().
- Add this object-type check directly below save_index():
fn expect_kind(object: &object::StoredObject, expected: &str) -> AppResult<()> {
if object.kind != expected {
return Err(format!("expected {expected} object, found {}", object.kind).into());
}
Ok(())
}
What does this helper do?
- The helper compares the stored type with the type required by the caller.
- A mismatch becomes an AppResult error before the CLI parses the wrong data format.
- Save src/repository.rs.
- Check that the new helper compiles by running this command:
cargo check
What does this command check?
Cargo checks the local package and its dependencies for compilation errors. It skips the final executable generation step for a faster feedback loop.
Cargo should report a successful check. That confirms the object-type guard fits the existing repository module.
Seeing a compilation error?
- Check that expect_kind() sits outside save_index().
- Confirm the parameter type is &object::StoredObject.
Help me fix the Rust compilation error in my expect_kind helper.
Commit objects are plain text records in this learning format. Parsing turns their tree, optional parent, and message fields back into a Commit value.
- In src/repository.rs, add this parser below expect_kind():
fn parse_commit(data: &[u8]) -> AppResult<Commit> {
let text = std::str::from_utf8(data)?;
let mut tree = None;
let mut parent = None;
let mut message = None;
for line in text.lines() {
if let Some(value) = line.strip_prefix("tree ") {
tree = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("parent ") {
parent = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("message ") {
message = Some(value.to_string());
}
}
Ok(Commit {
tree: tree.ok_or_else(|| "commit has no tree".to_string())?,
parent,
message: message.ok_or_else(|| "commit has no message".to_string())?,
})
}
How does commit parsing work?
- The parser converts the stored bytes into UTF-8 text.
- Each recognized prefix supplies one field in the Commit structure.
- Missing tree or message fields become errors because the CLI cannot use an incomplete commit.
- A missing parent remains valid because the first commit has no parent.
- At the bottom of src/repository.rs, add the history traversal function:
pub fn log() -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let mut current = head_commit(&root)?;
if current.is_none() {
println!("No commits yet.");
return Ok(());
}
while let Some(id) = current {
let object = object::read_object(&root, &id)?;
expect_kind(&object, "commit")?;
let commit = parse_commit(&object.data)?;
println!("commit {id}\n {}\n", commit.message);
current = commit.parent;
}
Ok(())
}
How does history traversal work?
- The traversal starts with the commit ID stored at the current branch tip.
- Each loop reads one commit object and prints its message.
- The parsed parent ID becomes the next commit to visit.
- A None parent ends the history walk at the first commit.
- Save src/repository.rs.
- Check the parser and traversal function by running this command:
cargo check
What does this check prove?
This check confirms that the parser returns the existing Commit type. It also confirms that log() can pass each parent ID into the next loop iteration.
Cargo should complete the check successfully. The repository module can now traverse commit ancestry.
History code not compiling?
- Confirm parse_commit() returns AppResult<Commit>.
- Check that the loop assigns commit.parent to current.
Help me debug my commit parser or log traversal.
- Switch to src/main.rs.
- Find the wildcard command arm that starts with _ =>.
- Add this dispatch arm directly above it:
"log" => repository::log(),
What does this dispatch arm do?
The log command now calls the repository traversal function. Any read or parse failure continues through the shared AppResult error path.
- Save src/main.rs.
Before you run the new command, how many commits do you expect the traversal to print?
- Walk the history from the current branch tip by running:
cargo run -- log
What does this command follow?
Cargo passes log to the rvcs binary. The command starts at HEAD and follows each optional parent.
You should see one commit containing the message first snapshot. The traversal stops because that first commit has no parent.
Resolve snapshots and show files
A commit points to a tree rather than directly to file contents. The tree maps notes.txt to the blob ID that captured its staged bytes.
- Return to src/repository.rs.
- Insert this tree parser between expect_kind() and parse_commit():
fn parse_tree(data: &[u8]) -> AppResult<BTreeMap<String, String>> {
let text = std::str::from_utf8(data)?;
let mut entries = BTreeMap::new();
for line in text.lines().filter(|line| !line.is_empty()) {
let (id, path) = line
.split_once('\t')
.ok_or_else(|| "tree entry is malformed".to_string())?;
entries.insert(path.to_string(), id.to_string());
}
Ok(entries)
}
How does the tree parser work?
- Each tree line contains an object ID followed by a tab and a path.
- The BTreeMap holds each path-to-blob relationship in sorted key order.
- Malformed entries become errors before the CLI tries to read a blob.
- Add this revision resolver directly below parse_commit():
fn resolve_revision(root: &Path, revision: &str) -> AppResult<String> {
if revision == "HEAD" {
return head_commit(root)?.ok_or_else(|| "repository has no commits".into());
}
Ok(revision.to_string())
}
What does revision resolution do?
The resolver converts HEAD into the current branch-tip commit ID. A supplied object ID passes through unchanged.
- Save src/repository.rs.
- Check both parsing helpers by running:
cargo check
What does this checkpoint confirm?
Cargo checks that tree entries produce the same map type used by the index. It also checks that revision resolution returns a commit ID through AppResult.
A successful check confirms that the read path can identify a commit and decode its tree.
Tree parsing not compiling?
- Check that parse_tree() returns BTreeMap<String, String> inside AppResult.
- Confirm the separator passed to split_once() is a tab character.
Help me debug my tree parser or revision resolver.
The final read path follows four links. It resolves a revision to a commit, follows the commit to a tree, finds the path's blob ID, and prints the blob contents.
- At the bottom of src/repository.rs, add the file reconstruction function:
pub fn show(revision: &str, path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
validate_root_file(path_text)?;
let commit_id = resolve_revision(&root, revision)?;
let commit_object = object::read_object(&root, &commit_id)?;
expect_kind(&commit_object, "commit")?;
let commit = parse_commit(&commit_object.data)?;
let tree_object = object::read_object(&root, &commit.tree)?;
expect_kind(&tree_object, "tree")?;
let entries = parse_tree(&tree_object.data)?;
let blob_id = entries
.get(path_text)
.ok_or_else(|| format!("file is not present in commit: {path_text}"))?;
let blob = object::read_object(&root, blob_id)?;
expect_kind(&blob, "blob")?;
print!("{}", String::from_utf8_lossy(&blob.data));
Ok(())
}
How does file reconstruction work?
- The function validates that the requested path names one root-level file.
- The resolved commit supplies the tree object ID.
- The parsed tree supplies the blob ID for the requested path.
- The blob bytes become the displayed committed contents.
- Switch back to src/main.rs.
- Add this show command arm directly below the log arm:
"show" => {
let revision = next_arg(&mut args, "revision")?;
let path = next_arg(&mut args, "file")?;
repository::show(&revision, &path)
}
What does the show dispatch collect?
The dispatch reads a revision followed by a filename. It passes both values to repository::show() through the existing argument and error helpers.
- Find the wildcard command arm in src/main.rs.
- Replace that arm with this completed usage message:
_ => Err(format!(
"unknown command: {command}\nusage: rvcs <init|hash-object|add|commit|log|show>"
)
.into()),
Why update the usage message?
Unknown commands now report the complete pre-bonus command set. The message includes both read operations that you added in this step.
- Save src/main.rs.
- Save src/repository.rs.
- Check the completed read path by running:
cargo check
What does the final code check cover?
This check connects argument dispatch to revision resolution and object parsing. A successful result confirms that the full read path compiles.
Cargo should report a successful check. Your CLI is ready to reconstruct committed content.
Show command not compiling?
- Confirm the show arm collects revision before file.
- Check that repository::show() receives references to both strings.
- Confirm the show() function reads the blob ID from the parsed tree.
Help me trace a compilation error in the FerrumVCS show command.
Use these references to compare the two files changed in this step.
✔️ Awesome, I've got everything!
Your src/main.rs file now dispatches all six pre-bonus commands.
ⓧ I'd like to double check the full code
mod object;
mod repository;
use std::{env, error::Error, process};
pub type AppResult<T> = Result<T, Box<dyn Error>>;
fn next_arg<I>(args: &mut I, name: &str) -> AppResult<String>
where
I: Iterator<Item = String>,
{
match args.next() {
Some(value) => Ok(value),
None => Err(format!("missing {name}").into()),
}
}
fn run() -> AppResult<()> {
let mut args = env::args().skip(1);
let command = next_arg(&mut args, "command")?;
match command.as_str() {
"init" => repository::init(),
"hash-object" => {
let path = next_arg(&mut args, "file")?;
repository::hash_object(&path)
}
"add" => {
let path = next_arg(&mut args, "file")?;
repository::add(&path)
}
"commit" => {
let message = args.collect::<Vec<_>>().join(" ");
if message.is_empty() {
return Err("missing commit message".into());
}
repository::commit(&message)
}
"log" => repository::log(),
"show" => {
let revision = next_arg(&mut args, "revision")?;
let path = next_arg(&mut args, "file")?;
repository::show(&revision, &path)
}
_ => Err(format!(
"unknown command: {command}\nusage: rvcs <init|hash-object|add|commit|log|show>"
)
.into()),
}
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
process::exit(1);
}
}
What should you compare?
Check the order of the log and show arms. Confirm the usage message names the same command set.
✔️ Awesome, I've got everything!
Your src/repository.rs file now contains the complete pre-bonus repository engine.
ⓧ I'd like to double check the full code
use std::{
collections::BTreeMap,
env, fs,
path::{Component, Path, PathBuf},
};
use crate::{object, AppResult};
const RVCS_DIR: &str = ".rvcs";
struct Commit {
tree: String,
parent: Option<String>,
message: String,
}
fn root() -> AppResult<PathBuf> {
Ok(env::current_dir()?)
}
fn ensure_repo(root: &Path) -> AppResult<()> {
if !root.join(RVCS_DIR).join("HEAD").is_file() {
return Err("not an rvcs repository; run `rvcs init` first".into());
}
Ok(())
}
fn validate_root_file(path_text: &str) -> AppResult<&Path> {
let path = Path::new(path_text);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(path),
_ => Err("this learning version accepts root-level files only".into()),
}
}
fn head_ref(root: &Path) -> AppResult<String> {
let head = fs::read_to_string(root.join(RVCS_DIR).join("HEAD"))?;
let reference = head
.trim()
.strip_prefix("ref: ")
.ok_or_else(|| "HEAD is not a symbolic reference".to_string())?;
if reference != "refs/heads/main" {
return Err(format!("unsupported HEAD reference: {reference}").into());
}
Ok(reference.to_string())
}
fn head_commit(root: &Path) -> AppResult<Option<String>> {
let reference = head_ref(root)?;
let path = root.join(RVCS_DIR).join(reference);
if !path.exists() {
return Ok(None);
}
let id = fs::read_to_string(path)?.trim().to_string();
if id.is_empty() {
Ok(None)
} else {
Ok(Some(id))
}
}
fn update_head(root: &Path, id: &str) -> AppResult<()> {
let reference = head_ref(root)?;
fs::write(root.join(RVCS_DIR).join(reference), format!("{id}\n"))?;
Ok(())
}
fn load_index(root: &Path) -> AppResult<BTreeMap<String, String>> {
let text = fs::read_to_string(root.join(RVCS_DIR).join("index"))?;
let mut entries = BTreeMap::new();
for line in text.lines().filter(|line| !line.is_empty()) {
let (id, path) = line
.split_once('\t')
.ok_or_else(|| "index entry is malformed".to_string())?;
entries.insert(path.to_string(), id.to_string());
}
Ok(entries)
}
fn save_index(root: &Path, entries: &BTreeMap<String, String>) -> AppResult<()> {
let mut text = String::new();
for (path, id) in entries {
text.push_str(&format!("{id}\t{path}\n"));
}
fs::write(root.join(RVCS_DIR).join("index"), text)?;
Ok(())
}
fn expect_kind(object: &object::StoredObject, expected: &str) -> AppResult<()> {
if object.kind != expected {
return Err(format!("expected {expected} object, found {}", object.kind).into());
}
Ok(())
}
fn parse_tree(data: &[u8]) -> AppResult<BTreeMap<String, String>> {
let text = std::str::from_utf8(data)?;
let mut entries = BTreeMap::new();
for line in text.lines().filter(|line| !line.is_empty()) {
let (id, path) = line
.split_once('\t')
.ok_or_else(|| "tree entry is malformed".to_string())?;
entries.insert(path.to_string(), id.to_string());
}
Ok(entries)
}
fn parse_commit(data: &[u8]) -> AppResult<Commit> {
let text = std::str::from_utf8(data)?;
let mut tree = None;
let mut parent = None;
let mut message = None;
for line in text.lines() {
if let Some(value) = line.strip_prefix("tree ") {
tree = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("parent ") {
parent = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("message ") {
message = Some(value.to_string());
}
}
Ok(Commit {
tree: tree.ok_or_else(|| "commit has no tree".to_string())?,
parent,
message: message.ok_or_else(|| "commit has no message".to_string())?,
})
}
fn resolve_revision(root: &Path, revision: &str) -> AppResult<String> {
if revision == "HEAD" {
return head_commit(root)?.ok_or_else(|| "repository has no commits".into());
}
Ok(revision.to_string())
}
pub fn init() -> AppResult<()> {
let root = root()?;
let repo = root.join(RVCS_DIR);
if repo.exists() {
return Err(".rvcs already exists".into());
}
fs::create_dir_all(repo.join("objects"))?;
fs::create_dir_all(repo.join("refs").join("heads"))?;
fs::write(repo.join("HEAD"), "ref: refs/heads/main\n")?;
fs::write(repo.join("index"), "")?;
println!("Initialized empty rvcs repository in {}", repo.display());
Ok(())
}
pub fn hash_object(path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let path = validate_root_file(path_text)?;
let data = fs::read(path)?;
let id = object::write_object(&root, "blob", &data)?;
println!("{id}");
Ok(())
}
pub fn add(path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let path = validate_root_file(path_text)?;
let data = fs::read(path)?;
let id = object::write_object(&root, "blob", &data)?;
let mut entries = load_index(&root)?;
entries.insert(path_text.to_string(), id.clone());
save_index(&root, &entries)?;
println!("staged {path_text} {id}");
Ok(())
}
pub fn commit(message: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
if message.contains('\n') || message.contains('\r') {
return Err("commit message must fit on one line".into());
}
let entries = load_index(&root)?;
if entries.is_empty() {
return Err("nothing is staged".into());
}
let mut tree_text = String::new();
for (path, id) in &entries {
tree_text.push_str(&format!("{id}\t{path}\n"));
}
let tree_id = object::write_object(&root, "tree", tree_text.as_bytes())?;
let mut commit_text = format!("tree {tree_id}\n");
if let Some(parent) = head_commit(&root)? {
commit_text.push_str(&format!("parent {parent}\n"));
}
commit_text.push_str(&format!("message {message}\n"));
let commit_id = object::write_object(&root, "commit", commit_text.as_bytes())?;
update_head(&root, &commit_id)?;
println!("committed {commit_id}");
Ok(())
}
pub fn log() -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
let mut current = head_commit(&root)?;
if current.is_none() {
println!("No commits yet.");
return Ok(());
}
while let Some(id) = current {
let object = object::read_object(&root, &id)?;
expect_kind(&object, "commit")?;
let commit = parse_commit(&object.data)?;
println!("commit {id}\n {}\n", commit.message);
current = commit.parent;
}
Ok(())
}
pub fn show(revision: &str, path_text: &str) -> AppResult<()> {
let root = root()?;
ensure_repo(&root)?;
validate_root_file(path_text)?;
let commit_id = resolve_revision(&root, revision)?;
let commit_object = object::read_object(&root, &commit_id)?;
expect_kind(&commit_object, "commit")?;
let commit = parse_commit(&commit_object.data)?;
let tree_object = object::read_object(&root, &commit.tree)?;
expect_kind(&tree_object, "tree")?;
let entries = parse_tree(&tree_object.data)?;
let blob_id = entries
.get(path_text)
.ok_or_else(|| format!("file is not present in commit: {path_text}"))?;
let blob = object::read_object(&root, blob_id)?;
expect_kind(&blob, "blob")?;
print!("{}", String::from_utf8_lossy(&blob.data));
Ok(())
}
What should you compare?
Confirm the helpers appear in the same order as this reference. Check that log() follows parent links while show() follows commit, tree, and blob links.
Verify the committed snapshot
The decisive test compares history with the working directory. Before you run it, do you expect show to print the staged version or the newer working version?
- Print the history and reconstruct notes.txt from HEAD by running:
cargo run -- log
cargo run -- show HEAD notes.txt
What do these commands test?
- The first command starts at the current branch tip and follows commit parent links.
- The second command resolves HEAD through the commit and tree before reading the referenced blob.
The history lists first snapshot. The reconstructed file output is version one.
- Switch back to the notes.txt tab in Visual Studio Code.
You should still see version two in the working file. The read command recovered the committed blob without changing the working directory.
Why does the committed content differ?
The earlier commit serialized the staging index into its tree. That index still pointed to the blob containing version one.
Editing notes.txt afterward changed only the working copy. Your object graph preserves the staged snapshot independently.
Seeing the wrong content?
- Confirm the show() function reads the blob ID from the parsed tree.
- Check that it does not call fs::read() on the working notes.txt file.
- Confirm .rvcs/index still maps notes.txt to the original blob ID.
Help me trace why rvcs show is returning the wrong file contents.
That completes the engine's read path. Your CLI can now walk history and reconstruct the exact staged content preserved by a commit.
Secret mission
Restore a Committed Snapshot
FerrumVCS can already inspect the snapshot stored at HEAD. Add checkout support that follows the object graph and restores every committed root-level file to the working directory.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local project, pause your work, or delete it completely. This Rust project runs entirely locally, so it has no ongoing cost.
Resources you used:
- The local project files inside ferrum-vcs, including Cargo.toml, src/main.rs, src/object.rs, src/repository.rs, and notes.txt.
- The local .rvcs object database containing the index, objects, HEAD, and refs/heads/main.
- The local Cargo build artifacts inside ferrum-vcs.
Keep everything running
No action needed. Choose this if you are still building FerrumVCS or want to keep it as a local demonstration.
- Leave the ferrum-vcs folder in its current location.
- Return to the project later in Visual Studio Code.
Your commits, index, restored working file, and object database remain ready for another run.
Pause - I'll come back to this later
Pause your work by closing the current terminal session while keeping every project file.
- Close the terminal from earlier.
- Keep the ferrum-vcs folder unchanged.
The rvcs CLI does not run a background service. Closing the terminal leaves no project process active.
Delete - I don't want to use this again
Remove all project resources to start fresh. Deleting this folder is permanent, so anything stored inside it disappears with the project.
- Copy anything you want to keep from ferrum-vcs into another folder.
- Switch back to the terminal from earlier.
- Delete the complete local project by running these commands:
cd ..
rm -rf ferrum-vcs
ls
What do these commands do?
- The first line moves the terminal one directory above the project.
- The second line permanently removes the entire ferrum-vcs folder.
- The final line lists the remaining folders. You should no longer see ferrum-vcs.
You are all clear. The project source, build artifacts, and .rvcs history are now removed.
Nice Work!
Nice Work!
That’s a wrap! Your Rust CLI now turns staged file contents into local history through a content-addressed object graph.
You’ve learned how to:
- Build a modular CLI with Cargo. Propagate failures through Result-based errors to one clear exit path.
- Create immutable blob, tree, and commit objects under deterministic SHA-256 IDs. Verify object integrity whenever FerrumVCS reads stored bytes.
- Keep the working file separate from the flat staging index. Follow commit parent links from HEAD to reconstruct the staged version of notes.txt.
- Extend FerrumVCS through the optional Secret Mission. The new checkout path restores every root-level file from a selected commit.
Ready to quiz yourself?