commit e0b6541e5bb2248da43482024c038464bf0916c9 Author: Alexis BAYLET Date: Sun Apr 12 17:02:01 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e2b78a4 --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7e6c9ce --- /dev/null +++ b/Cargo.toml @@ -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"] } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7d42880 --- /dev/null +++ b/src/main.rs @@ -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 }, + + /// 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) -> 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 `)".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 = repo.head_commit()?.into_iter().collect(); + + // 3. Create the commit object + let ts = format!("{} +0000", Utc::now().timestamp()); + let author = format!("User {}", 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 = Vec::new(); + let mut staged_modified: Vec = Vec::new(); + let mut staged_deleted: Vec = 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 = Vec::new(); + let mut unstaged_deleted: Vec = 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 = 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!("", 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, + out: &mut Vec, +) -> 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(()) +} diff --git a/src/objects.rs b/src/objects.rs new file mode 100644 index 0000000..c9689a8 --- /dev/null +++ b/src/objects.rs @@ -0,0 +1,197 @@ +/// Git-compatible object model. +/// +/// Every object is stored as: " \0" +/// 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), + Tree(Vec), + Commit { + tree: String, // SHA-1 hex of the root tree + parents: Vec, // 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) -> Self { + Object { kind: ObjKind::Blob, data: ObjData::Blob(content) } + } + + pub fn tree(entries: Vec) -> Self { + Object { kind: ObjKind::Tree, data: ObjData::Tree(entries) } + } + + pub fn commit( + tree: String, parents: Vec, + 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: " \0" + pub fn serialize(&self) -> Vec { + 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 { + 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 { + 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, String> { + let mut entries = Vec::new(); + let mut i = 0; + while i < data.len() { + // " \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 { + 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") }) +} diff --git a/src/repo.rs b/src/repo.rs new file mode 100644 index 0000000..0026d95 --- /dev/null +++ b/src/repo.rs @@ -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 { + 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 { + 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: + // + // + // (Real Git uses a binary format with stat info for fast dirty checking.) + + pub fn read_index(&self) -> Result, 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) -> Result<(), String> { + let mut lines: Vec = + 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) + // "" ← detached HEAD + // + // Branch files (refs/heads/main) contain a single SHA-1 line. + + pub fn read_head(&self) -> Result { + 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, 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) -> Result { + build_tree_recursive(&self.store, index, "") + } + + /// Read the tree for the current HEAD commit (path → hash). + pub fn head_tree(&self) -> Result, 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, + prefix: &str, +) -> Result { + let mut file_entries: Vec = Vec::new(); + let mut subdirs: BTreeSet = 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, 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) +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..7774391 --- /dev/null +++ b/src/store.rs @@ -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 { + 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 { + 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 { + 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 = 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())), + } + } +}