Implement basic version control system (VCS) with init, add, commit, log, status, and cat-file commands

- Added Cargo.toml for project configuration and dependencies.
- Created main.rs as the entry point for the VCS CLI application.
- Implemented command structure using Clap for parsing CLI arguments.
- Developed repository initialization and object storage functionality.
- Implemented staging of files and committing changes with message.
- Added functionality to view commit history and status of the working directory.
- Implemented object model for blobs, trees, and commits with serialization and deserialization.
- Created a low-level object store for reading and writing compressed object files.
- Implemented tree building and flattening for managing file structure in commits.
This commit is contained in:
Alexis BAYLET 2026-04-12 17:02:01 +02:00
commit e0b6541e5b
Signed by: alexis
GPG key ID: 6D10EC9A97F7F341
7 changed files with 1430 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

541
Cargo.lock generated Normal file
View file

@ -0,0 +1,541 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "cc"
version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "js-sys"
version = "0.3.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version"
version = "0.1.0"
dependencies = [
"chrono",
"clap",
"flate2",
"hex",
"sha1",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "version"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "vcs"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
sha1 = "0.10"
flate2 = "1.0"
hex = "0.4"
chrono = { version = "0.4", features = ["clock"] }

365
src/main.rs Normal file
View file

@ -0,0 +1,365 @@
mod objects;
mod repo;
mod store;
use chrono::Utc;
use clap::{Parser, Subcommand};
use objects::{ObjData, Object};
use repo::Repo;
use std::path::Path;
// ── CLI ────────────────────────────────────────────────────────────────────
#[derive(Parser)]
#[command(
name = "vcs",
about = "A mini Git — learn how version control works under the hood"
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Initialize a new repository in the current directory
Init,
/// Stage files for the next commit (like `git add`)
Add { files: Vec<String> },
/// Record a snapshot of staged changes (like `git commit`)
Commit {
#[arg(short, long)]
message: String,
},
/// Show the commit history (like `git log`)
Log,
/// Show staged and unstaged changes (like `git status`)
Status,
/// Display the raw content of any stored object (like `git cat-file -p`)
CatFile {
/// Full or abbreviated hash (≥4 chars)
hash: String,
/// Show object type only
#[arg(short = 't')]
type_only: bool,
},
/// Hash a file and optionally store it as a blob (like `git hash-object`)
HashObject {
file: String,
/// Write the blob into the object store
#[arg(short = 'w')]
write: bool,
},
}
// ── Entry point ────────────────────────────────────────────────────────────
fn main() {
// On Unix, restore the default SIGPIPE handler so piped commands like
// `vcs log | head` exit cleanly instead of panicking with "broken pipe".
#[cfg(unix)]
unsafe {
unsafe extern "C" { fn signal(sig: i32, handler: usize) -> usize; }
signal(13 /* SIGPIPE */, 0 /* SIG_DFL */);
}
let cli = Cli::parse();
let result = match cli.cmd {
Cmd::Init => cmd_init(),
Cmd::Add { files } => cmd_add(files),
Cmd::Commit { message } => cmd_commit(message),
Cmd::Log => cmd_log(),
Cmd::Status => cmd_status(),
Cmd::CatFile { hash, type_only } => cmd_cat_file(hash, type_only),
Cmd::HashObject { file, write } => cmd_hash_object(file, write),
};
if let Err(e) = result {
eprintln!("error: {}", e);
std::process::exit(1);
}
}
// ── Commands ───────────────────────────────────────────────────────────────
fn cmd_init() -> Result<(), String> {
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
Repo::init(&cwd)?;
Ok(())
}
/// Stage files: hash each one as a blob, write it to the object store,
/// then update the index with path → blob-hash.
fn cmd_add(files: Vec<String>) -> Result<(), String> {
let repo = Repo::find()?;
let mut index = repo.read_index()?;
for file in &files {
let path = Path::new(file);
if !path.exists() {
return Err(format!("'{}': no such file", file));
}
if path.is_dir() {
return Err(format!("'{}': is a directory — add individual files", file));
}
let content = std::fs::read(path).map_err(|e| e.to_string())?;
let blob = Object::blob(content);
let hash = repo.store.write(&blob)?;
// Normalise to a repo-relative path
let rel = path
.strip_prefix(&repo.root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
println!("staged: {} ({})", rel, &hash[..7]);
index.insert(rel, hash);
}
repo.write_index(&index)
}
/// Build a tree from the index, wrap it in a commit, update HEAD.
///
/// working dir ──add──► index ──commit──► object store (tree + commit)
/// ▲
/// HEAD / branch ref
fn cmd_commit(message: String) -> Result<(), String> {
let repo = Repo::find()?;
let index = repo.read_index()?;
if index.is_empty() {
return Err("nothing to commit (staging area is empty — run `vcs add <file>`)".into());
}
// 1. Build a tree object from the current index
let tree_hash = repo.build_tree(&index)?;
// 2. Chain to the current HEAD commit (if any)
let parents: Vec<String> = repo.head_commit()?.into_iter().collect();
// 3. Create the commit object
let ts = format!("{} +0000", Utc::now().timestamp());
let author = format!("User <user@local> {}", ts);
let commit = Object::commit(tree_hash, parents, author.clone(), author, message.clone());
// 4. Store it and advance the branch pointer
let commit_hash = repo.store.write(&commit)?;
repo.update_head(&commit_hash)?;
let first_line = message.lines().next().unwrap_or("");
println!("[{}] {}", &commit_hash[..7], first_line);
Ok(())
}
/// Walk the commit chain from HEAD backwards, printing each commit.
fn cmd_log() -> Result<(), String> {
let repo = Repo::find()?;
let mut cursor = repo.head_commit()?;
if cursor.is_none() {
println!("(no commits yet)");
return Ok(());
}
while let Some(hash) = cursor {
let obj = repo.store.read(&hash)?;
let ObjData::Commit { tree, parents, author, message, .. } = &obj.data else {
return Err(format!("{} is not a commit", &hash[..7]));
};
println!("commit {}", hash);
// Show just the name/email part (before the timestamp)
let author_short = author.rsplitn(3, ' ').last().unwrap_or(author);
println!("Author: {}", author_short);
println!("Tree: {}", &tree[..7]);
println!();
for line in message.lines() {
println!(" {}", line);
}
println!();
cursor = parents.first().cloned();
}
Ok(())
}
/// Compare three layers:
/// HEAD tree ←→ index ←→ working directory
fn cmd_status() -> Result<(), String> {
let repo = Repo::find()?;
let index = repo.read_index()?;
let head_tree = repo.head_tree()?;
// ── Staged changes (index vs HEAD) ─────────────────────────────────
let mut staged_new: Vec<String> = Vec::new();
let mut staged_modified: Vec<String> = Vec::new();
let mut staged_deleted: Vec<String> = Vec::new();
for (path, hash) in &index {
match head_tree.get(path) {
None => staged_new.push(path.clone()),
Some(h) if h != hash => staged_modified.push(path.clone()),
_ => {}
}
}
for path in head_tree.keys() {
if !index.contains_key(path) {
staged_deleted.push(path.clone());
}
}
// ── Unstaged changes (working dir vs index) ─────────────────────────
let mut unstaged_modified: Vec<String> = Vec::new();
let mut unstaged_deleted: Vec<String> = Vec::new();
for (path, index_hash) in &index {
let abs = repo.root.join(path);
if !abs.exists() {
unstaged_deleted.push(path.clone());
} else {
let content = std::fs::read(&abs).map_err(|e| e.to_string())?;
let disk_hash = Object::blob(content).hash_hex();
if disk_hash != *index_hash {
unstaged_modified.push(path.clone());
}
}
}
// ── Untracked files ─────────────────────────────────────────────────
let mut untracked: Vec<String> = Vec::new();
collect_untracked(&repo.root, &repo.root, &index, &mut untracked)?;
// ── Print ────────────────────────────────────────────────────────────
let anything = !staged_new.is_empty()
|| !staged_modified.is_empty()
|| !staged_deleted.is_empty()
|| !unstaged_modified.is_empty()
|| !unstaged_deleted.is_empty();
if anything {
if !staged_new.is_empty() || !staged_modified.is_empty() || !staged_deleted.is_empty() {
println!("Changes staged for commit:");
for f in &staged_new { println!(" new file: {}", f); }
for f in &staged_modified { println!(" modified: {}", f); }
for f in &staged_deleted { println!(" deleted: {}", f); }
println!();
}
if !unstaged_modified.is_empty() || !unstaged_deleted.is_empty() {
println!("Changes not staged for commit:");
for f in &unstaged_modified { println!(" modified: {}", f); }
for f in &unstaged_deleted { println!(" deleted: {}", f); }
println!();
}
}
if !untracked.is_empty() {
println!("Untracked files:");
for f in &untracked { println!(" {}", f); }
println!();
}
if !anything && untracked.is_empty() {
println!("nothing to commit, working tree clean");
}
Ok(())
}
/// Pretty-print any stored object.
fn cmd_cat_file(hash: String, type_only: bool) -> Result<(), String> {
let repo = Repo::find()?;
let obj = repo.store.read(&hash)?;
if type_only {
println!("{}", obj.kind);
return Ok(());
}
match &obj.data {
ObjData::Blob(bytes) => match std::str::from_utf8(bytes) {
Ok(s) => print!("{}", s),
Err(_) => println!("<binary blob: {} bytes>", bytes.len()),
},
ObjData::Tree(entries) => {
for e in entries {
let kind = if e.mode == "040000" { "tree" } else { "blob" };
println!("{} {} {}\t{}", e.mode, kind, hex::encode(&e.hash), e.name);
}
}
ObjData::Commit { tree, parents, author, committer, message } => {
println!("tree {}", tree);
for p in parents { println!("parent {}", p); }
println!("author {}", author);
println!("committer {}", committer);
println!();
println!("{}", message);
}
}
Ok(())
}
/// Hash a file as a blob. With -w, also write it to the object store.
fn cmd_hash_object(file: String, write: bool) -> Result<(), String> {
let content = std::fs::read(&file).map_err(|e| e.to_string())?;
let blob = Object::blob(content);
let hash = blob.hash_hex();
if write {
let repo = Repo::find()?;
repo.store.write(&blob)?;
println!("{}", hash);
} else {
// Dry run: just print, nothing stored
println!("{}", hash);
}
Ok(())
}
// ── Internal helpers ───────────────────────────────────────────────────────
/// Recursively walk the working directory and collect files not in the index.
fn collect_untracked(
base: &Path,
dir: &Path,
index: &std::collections::HashMap<String, String>,
out: &mut Vec<String>,
) -> Result<(), String> {
for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
// Skip the .version directory itself
if name == ".version" || name.starts_with('.') {
continue;
}
if path.is_dir() {
collect_untracked(base, &path, index, out)?;
} else {
let rel = path
.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if !index.contains_key(&rel) {
out.push(rel);
}
}
}
Ok(())
}

197
src/objects.rs Normal file
View file

@ -0,0 +1,197 @@
/// Git-compatible object model.
///
/// Every object is stored as: "<type> <size>\0<content>"
/// That header + content is SHA-1 hashed → the object's identity.
/// The bytes are then zlib-compressed and written to disk.
///
/// Three object types:
/// blob raw file content
/// tree sorted list of (mode, name, 20-byte-hash) entries
/// commit metadata pointing to a tree + optional parent commit(s)
use sha1::{Digest, Sha1};
use std::fmt;
// ── Types ──────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
pub enum ObjKind {
Blob,
Tree,
Commit,
}
impl fmt::Display for ObjKind {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ObjKind::Blob => write!(f, "blob"),
ObjKind::Tree => write!(f, "tree"),
ObjKind::Commit => write!(f, "commit"),
}
}
}
/// One entry inside a tree object.
#[derive(Debug, Clone)]
pub struct TreeEntry {
pub mode: String, // "100644" file | "040000" dir
pub name: String,
pub hash: [u8; 20], // raw 20-byte SHA-1
}
#[derive(Debug, Clone)]
pub enum ObjData {
Blob(Vec<u8>),
Tree(Vec<TreeEntry>),
Commit {
tree: String, // SHA-1 hex of the root tree
parents: Vec<String>, // SHA-1 hex of parent commit(s)
author: String,
committer: String,
message: String,
},
}
pub struct Object {
pub kind: ObjKind,
pub data: ObjData,
}
// ── Constructors ───────────────────────────────────────────────────────────
impl Object {
pub fn blob(content: Vec<u8>) -> Self {
Object { kind: ObjKind::Blob, data: ObjData::Blob(content) }
}
pub fn tree(entries: Vec<TreeEntry>) -> Self {
Object { kind: ObjKind::Tree, data: ObjData::Tree(entries) }
}
pub fn commit(
tree: String, parents: Vec<String>,
author: String, committer: String, message: String,
) -> Self {
Object {
kind: ObjKind::Commit,
data: ObjData::Commit { tree, parents, author, committer, message },
}
}
}
// ── Serialization ──────────────────────────────────────────────────────────
impl Object {
/// Full wire bytes: "<kind> <len>\0<body>"
pub fn serialize(&self) -> Vec<u8> {
let body = self.body_bytes();
let mut out = format!("{} {}\0", self.kind, body.len()).into_bytes();
out.extend(body);
out
}
fn body_bytes(&self) -> Vec<u8> {
match &self.data {
ObjData::Blob(b) => b.clone(),
ObjData::Tree(entries) => {
let mut out = Vec::new();
let mut sorted = entries.clone();
// Git sorts tree entries lexicographically by name
sorted.sort_by(|a, b| a.name.cmp(&b.name));
for e in sorted {
out.extend(format!("{} {}\0", e.mode, e.name).as_bytes());
out.extend(&e.hash); // raw 20 bytes, not hex
}
out
}
ObjData::Commit { tree, parents, author, committer, message } => {
let mut s = format!("tree {}\n", tree);
for p in parents { s.push_str(&format!("parent {}\n", p)); }
s.push_str(&format!("author {}\n", author));
s.push_str(&format!("committer {}\n", committer));
s.push('\n');
s.push_str(message);
s.into_bytes()
}
}
}
/// SHA-1 of the full wire bytes, as a 40-char hex string.
pub fn hash_hex(&self) -> String {
let mut h = Sha1::new();
h.update(self.serialize());
hex::encode(h.finalize())
}
}
// ── Deserialization ────────────────────────────────────────────────────────
impl Object {
pub fn parse(raw: &[u8]) -> Result<Self, String> {
let nul = raw.iter().position(|&b| b == 0).ok_or("object missing null byte")?;
let header = std::str::from_utf8(&raw[..nul]).map_err(|e| e.to_string())?;
let (type_str, _size) = header.split_once(' ').ok_or("malformed object header")?;
let body = &raw[nul + 1..];
match type_str {
"blob" => Ok(Object::blob(body.to_vec())),
"tree" => Ok(Object::tree(parse_tree(body)?)),
"commit" => {
let data = parse_commit(body)?;
Ok(Object { kind: ObjKind::Commit, data })
}
t => Err(format!("unknown object type: {}", t)),
}
}
}
fn parse_tree(data: &[u8]) -> Result<Vec<TreeEntry>, String> {
let mut entries = Vec::new();
let mut i = 0;
while i < data.len() {
// "<mode> <name>\0<20-bytes>"
let sp = data[i..].iter().position(|&b| b == b' ')
.ok_or("tree entry: missing space")?;
let mode = std::str::from_utf8(&data[i..i + sp])
.map_err(|e| e.to_string())?.to_string();
i += sp + 1;
let nul = data[i..].iter().position(|&b| b == 0)
.ok_or("tree entry: missing null")?;
let name = std::str::from_utf8(&data[i..i + nul])
.map_err(|e| e.to_string())?.to_string();
i += nul + 1;
if i + 20 > data.len() { return Err("tree entry: truncated hash".into()); }
let mut hash = [0u8; 20];
hash.copy_from_slice(&data[i..i + 20]);
i += 20;
entries.push(TreeEntry { mode, name, hash });
}
Ok(entries)
}
fn parse_commit(data: &[u8]) -> Result<ObjData, String> {
let text = std::str::from_utf8(data).map_err(|e| e.to_string())?;
let mut tree = String::new();
let mut parents = Vec::new();
let mut author = String::new();
let mut committer = String::new();
let mut msg_lines: Vec<&str> = Vec::new();
let mut in_msg = false;
for line in text.lines() {
if in_msg {
msg_lines.push(line);
} else if line.is_empty() {
in_msg = true;
} else if let Some(v) = line.strip_prefix("tree ") { tree = v.to_string(); }
else if let Some(v) = line.strip_prefix("parent ") { parents.push(v.to_string()); }
else if let Some(v) = line.strip_prefix("author ") { author = v.to_string(); }
else if let Some(v) = line.strip_prefix("committer ") { committer = v.to_string(); }
}
Ok(ObjData::Commit { tree, parents, author, committer, message: msg_lines.join("\n") })
}

211
src/repo.rs Normal file
View file

@ -0,0 +1,211 @@
/// High-level repository operations: init, index (staging area), refs, tree building.
use std::collections::{BTreeSet, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use crate::objects::{ObjData, Object, TreeEntry};
use crate::store::Store;
// ── Repository ─────────────────────────────────────────────────────────────
pub struct Repo {
pub root: PathBuf, // working-directory root (where .version/ lives)
pub dot: PathBuf, // .version/
pub store: Store,
}
impl Repo {
// ── Open / Init ────────────────────────────────────────────────────────
/// Walk up from cwd until we find a .version/ directory.
pub fn find() -> Result<Self, String> {
let mut dir = std::env::current_dir().map_err(|e| e.to_string())?;
loop {
let dot = dir.join(".version");
if dot.is_dir() {
return Ok(Repo { store: Store::new(dot.clone()), root: dir, dot });
}
if !dir.pop() {
return Err(
"not a vcs repository (no .version directory found — run `vcs init`)".into(),
);
}
}
}
/// Create a new repository in `path`.
pub fn init(path: &Path) -> Result<Self, String> {
let dot = path.join(".version");
fs::create_dir_all(dot.join("objects")).map_err(|e| e.to_string())?;
fs::create_dir_all(dot.join("refs/heads")).map_err(|e| e.to_string())?;
// HEAD starts on branch "main" (symbolic ref)
fs::write(dot.join("HEAD"), "ref: refs/heads/main\n").map_err(|e| e.to_string())?;
// Empty index
fs::write(dot.join("index"), "").map_err(|e| e.to_string())?;
println!("Initialized empty repository in {}", dot.display());
Ok(Repo { store: Store::new(dot.clone()), root: path.to_path_buf(), dot })
}
// ── Index (staging area) ───────────────────────────────────────────────
//
// Simple text format, one entry per line:
// <sha1-hex> <repo-relative-path>
//
// (Real Git uses a binary format with stat info for fast dirty checking.)
pub fn read_index(&self) -> Result<HashMap<String, String>, String> {
let text = fs::read_to_string(self.dot.join("index")).map_err(|e| e.to_string())?;
let mut map = HashMap::new();
for line in text.lines() {
if line.is_empty() { continue; }
let (hash, path) = line.split_once(' ').ok_or("index: malformed line")?;
map.insert(path.to_string(), hash.to_string());
}
Ok(map)
}
pub fn write_index(&self, index: &HashMap<String, String>) -> Result<(), String> {
let mut lines: Vec<String> =
index.iter().map(|(p, h)| format!("{} {}", h, p)).collect();
lines.sort();
let content = lines.join("\n") + if lines.is_empty() { "" } else { "\n" };
fs::write(self.dot.join("index"), content).map_err(|e| e.to_string())
}
// ── Refs ───────────────────────────────────────────────────────────────
//
// HEAD is either:
// "ref: refs/heads/main" ← symbolic ref (normal case)
// "<sha1>" ← detached HEAD
//
// Branch files (refs/heads/main) contain a single SHA-1 line.
pub fn read_head(&self) -> Result<String, String> {
fs::read_to_string(self.dot.join("HEAD"))
.map(|s| s.trim().to_string())
.map_err(|e| e.to_string())
}
/// Resolve HEAD to a commit hash, or None if no commits yet.
pub fn head_commit(&self) -> Result<Option<String>, String> {
let head = self.read_head()?;
if let Some(refname) = head.strip_prefix("ref: ") {
let ref_path = self.dot.join(refname);
if !ref_path.exists() { return Ok(None); }
let hash = fs::read_to_string(ref_path).map_err(|e| e.to_string())?;
Ok(Some(hash.trim().to_string()))
} else {
Ok(Some(head))
}
}
/// Update the branch that HEAD points to (or HEAD itself if detached).
pub fn update_head(&self, hash: &str) -> Result<(), String> {
let head = self.read_head()?;
if let Some(refname) = head.strip_prefix("ref: ") {
let ref_path = self.dot.join(refname);
fs::create_dir_all(ref_path.parent().unwrap()).map_err(|e| e.to_string())?;
fs::write(ref_path, format!("{}\n", hash)).map_err(|e| e.to_string())
} else {
fs::write(self.dot.join("HEAD"), format!("{}\n", hash)).map_err(|e| e.to_string())
}
}
// ── Tree building ──────────────────────────────────────────────────────
/// Build a tree object hierarchy from the flat index map and return the
/// root tree hash. Handles subdirectories by recursing.
pub fn build_tree(&self, index: &HashMap<String, String>) -> Result<String, String> {
build_tree_recursive(&self.store, index, "")
}
/// Read the tree for the current HEAD commit (path → hash).
pub fn head_tree(&self) -> Result<HashMap<String, String>, String> {
let Some(commit_hash) = self.head_commit()? else {
return Ok(HashMap::new());
};
let commit = self.store.read(&commit_hash)?;
let ObjData::Commit { tree, .. } = &commit.data else {
return Err("HEAD is not a commit object".into());
};
flat_tree(&self.store, tree, "")
}
}
// ── Free helpers ───────────────────────────────────────────────────────────
/// Recursively create tree objects for `prefix` directory and below.
/// `index` is the flat map { "src/main.rs" → sha1-hex, ... }.
fn build_tree_recursive(
store: &Store,
index: &HashMap<String, String>,
prefix: &str,
) -> Result<String, String> {
let mut file_entries: Vec<TreeEntry> = Vec::new();
let mut subdirs: BTreeSet<String> = BTreeSet::new();
for (path, hash_hex) in index {
// Compute the path relative to current prefix
let rel = if prefix.is_empty() {
path.as_str()
} else {
match path.strip_prefix(&format!("{}/", prefix)) {
Some(r) => r,
None => continue, // not under this prefix
}
};
if let Some(slash) = rel.find('/') {
// There is a subdirectory component
subdirs.insert(rel[..slash].to_string());
} else {
// Direct file entry
let mut hash = [0u8; 20];
hex::decode_to_slice(hash_hex, &mut hash)
.map_err(|e| format!("index has bad hash '{}': {}", hash_hex, e))?;
file_entries.push(TreeEntry { mode: "100644".into(), name: rel.to_string(), hash });
}
}
// Recurse into each subdirectory and collect its tree hash
let mut entries = file_entries;
for subdir in subdirs {
let child_prefix = if prefix.is_empty() {
subdir.clone()
} else {
format!("{}/{}", prefix, subdir)
};
let child_hash = build_tree_recursive(store, index, &child_prefix)?;
let mut hash = [0u8; 20];
hex::decode_to_slice(&child_hash, &mut hash)
.map_err(|e| format!("bad child tree hash: {}", e))?;
entries.push(TreeEntry { mode: "040000".into(), name: subdir, hash });
}
store.write(&Object::tree(entries))
}
/// Flatten a tree object into a { path → sha1-hex } map (recurses into subtrees).
fn flat_tree(store: &Store, tree_hash: &str, prefix: &str) -> Result<HashMap<String, String>, String> {
let obj = store.read(tree_hash)?;
let ObjData::Tree(entries) = &obj.data else {
return Err(format!("{} is not a tree", &tree_hash[..7]));
};
let mut map = HashMap::new();
for e in entries {
let full_name = if prefix.is_empty() {
e.name.clone()
} else {
format!("{}/{}", prefix, e.name)
};
if e.mode == "040000" {
// subtree — recurse
let sub = flat_tree(store, &hex::encode(&e.hash), &full_name)?;
map.extend(sub);
} else {
map.insert(full_name, hex::encode(&e.hash));
}
}
Ok(map)
}

100
src/store.rs Normal file
View file

@ -0,0 +1,100 @@
/// Low-level object store: read/write zlib-compressed object files.
///
/// Layout inside .version/objects/:
/// ab/cdef1234... (first 2 hex chars = directory, rest = filename)
///
/// This mirrors Git's "loose object" storage exactly.
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use flate2::{read::ZlibDecoder, write::ZlibEncoder, Compression};
use crate::objects::Object;
pub struct Store {
pub root: PathBuf, // path to .version/
}
impl Store {
pub fn new(root: PathBuf) -> Self {
Store { root }
}
// ── Write ──────────────────────────────────────────────────────────────
/// Hash the object, compress it, and write it under objects/<2>/<38>.
/// Returns the 40-char hex hash.
pub fn write(&self, obj: &Object) -> Result<String, String> {
let hash = obj.hash_hex();
let path = self.object_path(&hash);
// Idempotent: skip if already stored.
if path.exists() {
return Ok(hash);
}
fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
let raw = obj.serialize();
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
enc.write_all(&raw).map_err(|e| e.to_string())?;
let compressed = enc.finish().map_err(|e| e.to_string())?;
fs::write(&path, compressed).map_err(|e| e.to_string())?;
Ok(hash)
}
// ── Read ───────────────────────────────────────────────────────────────
/// Decompress and parse an object by its hash (full or abbreviated).
pub fn read(&self, hash: &str) -> Result<Object, String> {
let full = self.resolve(hash)?;
let path = self.object_path(&full);
let compressed = fs::read(&path)
.map_err(|_| format!("object not found: {}", &full[..7]))?;
let mut dec = ZlibDecoder::new(&compressed[..]);
let mut raw = Vec::new();
dec.read_to_end(&mut raw).map_err(|e| e.to_string())?;
Object::parse(&raw)
}
// ── Helpers ────────────────────────────────────────────────────────────
fn object_path(&self, hash: &str) -> PathBuf {
let (dir, file) = hash.split_at(2);
self.root.join("objects").join(dir).join(file)
}
/// Resolve an abbreviated hash (minimum 4 chars) to the full 40-char form.
fn resolve(&self, prefix: &str) -> Result<String, String> {
if prefix.len() == 40 {
return Ok(prefix.to_string());
}
if prefix.len() < 4 {
return Err("hash prefix too short (need at least 4 chars)".into());
}
let (dir, rest) = prefix.split_at(2);
let dir_path = self.root.join("objects").join(dir);
if !dir_path.exists() {
return Err(format!("no object starting with {}", prefix));
}
let mut matches: Vec<String> = fs::read_dir(&dir_path)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|name| name.starts_with(rest))
.map(|name| format!("{}{}", dir, name))
.collect();
match matches.len() {
0 => Err(format!("no object starting with {}", prefix)),
1 => Ok(matches.remove(0)),
_ => Err(format!("ambiguous prefix '{}' ({} matches)", prefix, matches.len())),
}
}
}