diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..637cb3e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: ["main", "master"] + pull_request: + branches: ["main", "master"] + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Cargo dependencies + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Lint with Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Run Unit and Integration tests + run: cargo test --all diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7bf31f..3065995 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,3 +67,12 @@ refactor(object): eliminate intermediate heap allocations in create_object Replaced format!().as_bytes() + extend_from_slice patterns with the write! macro. ``` + +## Continuous Integration (CI) + +All Pull Requests are automatically tested against our GitHub Actions pipeline. To ensure your PR is green, run the following locally before pushing: + +```bash +cargo fmt --all +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all diff --git a/Cargo.lock b/Cargo.lock index 28812fd..d221598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,27 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "block-buffer" version = "0.12.0" @@ -73,6 +94,17 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bstr" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e308e67087593070530a666f7fe8815cbd2e61d8f5b7313b71b7d721d1dfde" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -158,6 +190,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.11.3" @@ -175,6 +213,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "flate2" version = "1.1.9" @@ -185,16 +239,29 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "git-rs" version = "0.2.0" dependencies = [ "anyhow", + "assert_cmd", "clap", "flate2", "hex", "serde", "sha1", + "tempfile", "thiserror", "toml", ] @@ -248,6 +315,18 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -258,12 +337,45 @@ dependencies = [ "simd-adler32", ] +[[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 = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -282,6 +394,31 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "serde" version = "1.0.228" @@ -355,6 +492,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -432,6 +588,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 6e72c0c..9c0f7d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,4 +22,6 @@ thiserror = "2.0.18" anyhow = "1.0" serde = {version = "1.0", features = ["derive"]} toml = "1.1.2+spec-1.1.0" -clap = { version = "4.5", features = ["derive"] } \ No newline at end of file +clap = { version = "4.5", features = ["derive"] } +tempfile = "3.10" +assert_cmd = "2.0" \ No newline at end of file diff --git a/README.md b/README.md index 1fd2450..4e7070c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ License

+[![CI](https://github.com//git-rs/actions/workflows/ci.yml/badge.svg)](https://github.com//git-rs/actions/workflows/ci.yml) + [Overview](#overview) • [Architecture](#architecture) • [Quick Start](#quick-start) • [Roadmap](#roadmap) diff --git a/src/commit.rs b/src/commit.rs index 200f98a..215146a 100644 --- a/src/commit.rs +++ b/src/commit.rs @@ -1,6 +1,7 @@ use crate::{config::get_author, object::write_object}; use std::{ io::Write, + path::Path, time::{SystemTime, UNIX_EPOCH}, }; use thiserror::Error; @@ -40,9 +41,10 @@ fn create_commit( tree_hash: &str, commit_message: &str, parent_hash: Option<&str>, + dir: &Path, ) -> Result { let time_stamp = get_timestamp()?; - let (name, email) = get_author(); + let (name, email) = get_author(dir); let author = Signature { name: name.clone(), @@ -66,7 +68,7 @@ fn create_commit( Ok(commit) } -fn write_commit(commit: &Commit) -> Result { +fn write_commit(commit: &Commit, dir: &Path) -> Result { let mut serialized = Vec::new(); writeln!(&mut serialized, "tree {}", commit.tree)?; if let Some(parent_hash) = &commit.parent { @@ -86,7 +88,7 @@ fn write_commit(commit: &Commit) -> Result { commit.committer.timezone )?; write!(&mut serialized, "\n{}\n", commit.message)?; - let oid = write_object("commit", &serialized)?; + let oid = write_object("commit", &serialized, dir)?; Ok(oid) } @@ -94,8 +96,96 @@ pub fn write_commit_object( tree_hash: &str, commit_message: &str, parent_hash: Option<&str>, + dir: &Path, ) -> Result { - let commit = create_commit(tree_hash, commit_message, parent_hash)?; - let commit_hash = write_commit(&commit)?; + let commit = create_commit(tree_hash, commit_message, parent_hash, dir)?; + let commit_hash = write_commit(&commit, dir)?; Ok(commit_hash) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::object::read_object; + use std::fs; + use tempfile::tempdir; + + fn setup_git_dir(dir: &std::path::Path) { + fs::create_dir_all(dir.join(".git/objects")).unwrap(); + fs::create_dir_all(dir.join(".git/info")).unwrap(); + fs::create_dir_all(dir.join(".git/pack")).unwrap(); + } + + #[test] + fn test_write_commit_no_parent() { + let dir = tempdir().unwrap(); + setup_git_dir(dir.path()); + + // Mock config + fs::write( + dir.path().join(".git/config"), + "[user]\nname = \"Alice\"\nemail = \"alice@test.com\"\n", + ) + .unwrap(); + + let hash = write_commit_object("tree_hash_123", "my message", None, dir.path()).unwrap(); + assert_eq!(hash.len(), 40); + + let (kind, content) = read_object(&hash, dir.path()).unwrap(); + assert_eq!(kind, "commit"); + let text = String::from_utf8(content).unwrap(); + + assert!(text.contains("tree tree_hash_123")); + assert!(text.contains("author Alice ")); + assert!(text.contains("\nmy message\n")); + assert!( + !text.contains("parent"), + "Root commit should NOT have a parent header" + ); + } + + #[test] + fn test_write_commit_with_parent() { + let dir = tempdir().unwrap(); + setup_git_dir(dir.path()); + fs::write( + dir.path().join(".git/config"), + "[user]\nname = \"Bob\"\nemail = \"bob@test.com\"\n", + ) + .unwrap(); + + let hash = write_commit_object("tree_hash_123", "msg", Some("parent_hash_456"), dir.path()) + .unwrap(); + let (_, content) = read_object(&hash, dir.path()).unwrap(); + let text = String::from_utf8(content).unwrap(); + + assert!( + text.contains("parent parent_hash_456"), + "Child commit MUST have parent header" + ); + } + + #[test] + fn test_commit_fallback_unknown_user() { + let dir = tempdir().unwrap(); + setup_git_dir(dir.path()); + // Intentionally DO NOT create .git/config + + let hash = write_commit_object("tree_hash", "msg", None, dir.path()).unwrap(); + let (_, content) = read_object(&hash, dir.path()).unwrap(); + let text = String::from_utf8(content).unwrap(); + + assert!( + text.contains("unknown_user"), + "Should fallback to unknown_user when config is missing" + ); + assert!(text.contains("unknown@localhost")); + } + + #[test] + fn test_get_timestamp_is_modern() { + let ts = get_timestamp().unwrap(); + // Assert it's greater than Jan 1, 2024 + assert!(ts > 1704067200); + } +} diff --git a/src/config.rs b/src/config.rs index 58ea999..4234311 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +use std::path::Path; + #[derive(serde::Deserialize)] pub struct GitConfig { pub user: UserConfig, @@ -8,13 +10,13 @@ pub struct UserConfig { pub email: String, } -pub fn get_author() -> (String, String) { +pub fn get_author(dir: &Path) -> (String, String) { let unknown_user = UserConfig { name: "unknown_user".to_string(), email: "unknown@localhost".to_string(), }; - - let contents = std::fs::read_to_string(".git/config").unwrap_or_default(); + let config_dir = dir.join(".git").join("config"); + let contents = std::fs::read_to_string(config_dir).unwrap_or_default(); let parsed = toml::from_str(&contents).unwrap_or(GitConfig { user: unknown_user }); (parsed.user.name, parsed.user.email) } diff --git a/src/main.rs b/src/main.rs index b0f5222..e0e0333 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,36 +76,38 @@ fn main() -> anyhow::Result<()> { // The magic happens here! clap reads env::args(), validates everything, // and populates the Cli struct. let cli = Cli::parse(); + let repodir = + std::env::current_dir().context("Failed to determine the current working directory")?; match cli.command { - Commands::Init => cmd_init(), + Commands::Init => cmd_init(&repodir), Commands::CatFile { pretty, r#type, size, hash, - } => cmd_cat_file(pretty, r#type, size, &hash), + } => cmd_cat_file(pretty, r#type, size, &hash, &repodir), - Commands::HashObject { write, file } => cmd_hash_object(&file, write), + Commands::HashObject { write, file } => cmd_hash_object(&file, write, &repodir), Commands::WriteTree => { - let tree_hash = cmd_write_tree(Path::new("."))?; + let tree_hash = cmd_write_tree(Path::new("."), &repodir)?; println!("{}", tree_hash); Ok(()) } // FIXED: Used the correct destructured variables, added `?` and `println!` Commands::CommitTree { tree_hash, message } => { - let commit_hash = cmd_write_commit(&tree_hash, &message, None)?; + let commit_hash = cmd_write_commit(&tree_hash, &message, None, &repodir)?; println!("{}", commit_hash); Ok(()) } // ADDED: The missing Commit match arm Commands::Commit { message } => { - let new_commit_hash = cmd_commit(&message)?; - update_current_ref(&new_commit_hash)?; + let new_commit_hash = cmd_commit(&message, &repodir)?; + update_current_ref(&new_commit_hash, &repodir)?; println!("{}", new_commit_hash); Ok(()) } @@ -114,23 +116,32 @@ fn main() -> anyhow::Result<()> { // DELETED: expect_args and run functions are no longer needed! -fn cmd_init() -> anyhow::Result<()> { - fs::create_dir_all(".git/objects/info")?; - fs::create_dir_all(".git/objects/pack/")?; - fs::create_dir_all(".git/refs/heads/")?; - fs::create_dir_all(".git/refs/tags/")?; - fs::write(".git/HEAD", "ref: refs/heads/main\n")?; - if !Path::new(".git/config").exists() { +fn cmd_init(repo_dir: &Path) -> anyhow::Result<()> { + let git_dir = repo_dir.join(".git"); + fs::create_dir_all(git_dir.join("objects/info"))?; + fs::create_dir_all(git_dir.join("objects/pack/"))?; + fs::create_dir_all(git_dir.join("refs/heads/"))?; + fs::create_dir_all(git_dir.join("refs/tags/"))?; + fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n")?; + + let config_path = git_dir.join("config"); + if !config_path.exists() { fs::write( - ".git/config", + config_path, "[user]\nname = \"Your Name\"\nemail = \"you@example.com\"\n", )?; } Ok(()) } -fn cmd_cat_file(pretty: bool, show_type: bool, show_size: bool, hash: &str) -> anyhow::Result<()> { - let (kind, content) = read_object(hash).context("Failed to read git object from disk")?; +fn cmd_cat_file( + pretty: bool, + show_type: bool, + show_size: bool, + hash: &str, + dir: &Path, +) -> anyhow::Result<()> { + let (kind, content) = read_object(hash, dir).context("Failed to read git object from disk")?; if show_type { println!("{}", kind); @@ -144,10 +155,10 @@ fn cmd_cat_file(pretty: bool, show_type: bool, show_size: bool, hash: &str) -> a Ok(()) } -fn cmd_hash_object(file: &str, write: bool) -> anyhow::Result<()> { +fn cmd_hash_object(file: &str, write: bool, dir: &Path) -> anyhow::Result<()> { let content = fs::read(file)?; if write { - let hash = write_object("blob", &content).context("Failed to write object")?; + let hash = write_object("blob", &content, dir).context("Failed to write object")?; println!("{}", hash); } else { bail!("Without -w, hashing without writing is not yet implemented. Please use -w."); @@ -155,8 +166,8 @@ fn cmd_hash_object(file: &str, write: bool) -> anyhow::Result<()> { Ok(()) } -fn cmd_write_tree(path: &Path) -> anyhow::Result { - let tree_hash = write_tree(path).context("Failed to write tree")?; +fn cmd_write_tree(path: &Path, dir: &Path) -> anyhow::Result { + let tree_hash = write_tree(path, dir).context("Failed to write tree")?; Ok(tree_hash) } @@ -165,19 +176,21 @@ fn cmd_write_commit( tree_hash: &str, commit_message: &str, parent_hash: Option<&str>, + dir: &Path, ) -> anyhow::Result { - let commit_hash = write_commit_object(tree_hash, commit_message, parent_hash) + let commit_hash = write_commit_object(tree_hash, commit_message, parent_hash, dir) .context("Failed to write commit to disk")?; Ok(commit_hash) } -fn cmd_commit(commit_message: &str) -> anyhow::Result { +fn cmd_commit(commit_message: &str, dir: &Path) -> anyhow::Result { let current_path = Path::new("."); - let tree_hash = write_tree(current_path).context("Failed to snapshot working directory")?; - let path = read_head().context("Failed to read HEAD pointer")?; - let ref_content = read_ref(&path).context("Failed to read current branch reference")?; + let tree_hash = + write_tree(current_path, dir).context("Failed to snapshot working directory")?; + let path = read_head(dir).context("Failed to read HEAD pointer")?; + let ref_content = read_ref(&path, dir).context("Failed to read current branch reference")?; - let commit_hash = write_commit_object(&tree_hash, commit_message, ref_content.as_deref()) + let commit_hash = write_commit_object(&tree_hash, commit_message, ref_content.as_deref(), dir) .context("Failed to write commit to disk")?; Ok(commit_hash) } diff --git a/src/object.rs b/src/object.rs index c172150..8e8d2c1 100644 --- a/src/object.rs +++ b/src/object.rs @@ -3,7 +3,7 @@ use sha1::{Digest, Sha1}; use std::{ fs, io::{Read, Write}, - path::PathBuf, + path::{Path, PathBuf}, }; use thiserror::Error; @@ -64,9 +64,9 @@ fn compress_object(object: &[u8]) -> Result, ObjectError> { Ok(compressed?) } -pub fn read_object(hash: &str) -> Result<(String, Vec), ObjectError> { +pub fn read_object(hash: &str, dir: &Path) -> Result<(String, Vec), ObjectError> { // 1. Resolve the filesystem path for this hash - let path = object_path(hash)?; + let path = object_path(hash, dir)?; // 2. Read the compressed bytes from disk let compressed = fs::read(&path)?; @@ -105,20 +105,124 @@ pub fn read_object(hash: &str) -> Result<(String, Vec), ObjectError> { Ok((kind.to_string(), content)) } -fn object_path(hash: &str) -> Result { - let base = ".git/objects/"; +fn object_path(hash: &str, repo_dir: &Path) -> Result { + if hash.len() != 40 { + return Err(ObjectError::InvalidHashLength); + } + let base = repo_dir.join(".git").join("objects"); let file_name = hash.get(2..).ok_or(ObjectError::InvalidHashLength)?; let dir = hash.get(..2).ok_or(ObjectError::InvalidHashLength)?; - let path = PathBuf::from(base).join(dir).join(file_name); + let path = base.join(dir).join(file_name); Ok(path) } -pub fn write_object(kind: &str, content: &[u8]) -> Result { +pub fn write_object(kind: &str, content: &[u8], dir: &Path) -> Result { let object = create_object(kind, content)?; let hashed_object = hash_object(&object); let compressed_object = compress_object(&object)?; - let path = object_path(&hashed_object)?; + let path = object_path(&hashed_object, dir)?; fs::create_dir_all(path.parent().ok_or(ObjectError::InvalidObjectPath)?)?; fs::write(path, compressed_object)?; Ok(hashed_object) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + // --- PURE TESTS --- + #[test] + fn test_create_object_formatting() { + let result = create_object("blob", b"test content").unwrap(); + let mut expected = b"blob 12\0".to_vec(); + expected.extend_from_slice(b"test content"); + assert_eq!(result, expected); + } + + #[test] + fn test_create_object_empty() { + let result = create_object("blob", b"").unwrap(); + assert_eq!(result, b"blob 0\0".to_vec()); + } + + #[test] + fn test_hash_object_known_value() { + let obj = b"blob 0\0".to_vec(); + let hash = hash_object(&obj); + assert_eq!(hash, "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"); + } + + #[test] + fn test_object_path_valid() { + let dir = tempdir().unwrap(); + let hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let path = object_path(hash, dir.path()).unwrap(); + assert_eq!( + path, + dir.path() + .join(".git/objects/a1/b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2") + ); + } + + #[test] + fn test_object_path_too_short() { + let dir = tempdir().unwrap(); + let result = object_path("abc", dir.path()); + assert!(matches!( + result.unwrap_err(), + ObjectError::InvalidHashLength + )); + } + + // --- IMPURE TESTS (Now completely isolated!) --- + #[test] + fn test_write_and_read_roundtrip() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git/objects")).unwrap(); + + let content = b"hello world"; + let hash = write_object("blob", content, dir.path()).unwrap(); + let (kind, read_content) = read_object(&hash, dir.path()).unwrap(); + + assert_eq!(kind, "blob"); + assert_eq!(read_content, content); + } + + #[test] + fn test_read_object_corrupt_missing_null() { + let dir = tempdir().unwrap(); + let hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let path = object_path(hash, dir.path()).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + + let mut compressor = ZlibEncoder::new(Vec::new(), Compression::default()); + compressor.write_all(b"blob 5hello").unwrap(); + fs::write(path, compressor.finish().unwrap()).unwrap(); + + let result = read_object(hash, dir.path()); + assert!(matches!( + result.unwrap_err(), + ObjectError::MissingNullSeparator + )); + } + + #[test] + fn test_read_object_corrupt_size_mismatch() { + let dir = tempdir().unwrap(); + let hash = "b1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + let path = object_path(hash, dir.path()).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + + let mut compressor = ZlibEncoder::new(Vec::new(), Compression::default()); + compressor.write_all(b"blob 99\0hello").unwrap(); + fs::write(path, compressor.finish().unwrap()).unwrap(); + + let result = read_object(hash, dir.path()); + assert!(matches!( + result.unwrap_err(), + ObjectError::SizeMismatch { .. } + )); + } +} diff --git a/src/refs.rs b/src/refs.rs index 2795ed8..2abbe22 100644 --- a/src/refs.rs +++ b/src/refs.rs @@ -1,4 +1,3 @@ -use std::path::PathBuf; use std::{fs, path::Path}; use thiserror::Error; @@ -13,17 +12,18 @@ pub enum RefsError { DetachedHead, } -pub fn read_head() -> Result { - let path = fs::read_to_string(".git/HEAD")?; - let clean_path = path +pub fn read_head(dir: &Path) -> Result { + let path = dir.join(".git").join("HEAD"); + let contents = fs::read_to_string(path)?; + let clean_path = contents .trim() .strip_prefix("ref: ") .ok_or(RefsError::DetachedHead)?; Ok(clean_path.to_string()) } -pub fn read_ref(path: &str) -> Result, RefsError> { - let path = PathBuf::from(".git/").join(path); +pub fn read_ref(path: &str, dir: &Path) -> Result, RefsError> { + let path = dir.join(".git").join(path); if !path.exists() { return Ok(None); } @@ -32,9 +32,71 @@ pub fn read_ref(path: &str) -> Result, RefsError> { Ok(Some(cleaned.to_string())) } -pub fn update_current_ref(new_head_commit_hash: &str) -> Result<(), RefsError> { - let head_file_path = Path::new(".git").join(read_head()?); +pub fn update_current_ref(new_head_commit_hash: &str, dir: &Path) -> Result<(), RefsError> { + let ref_path = read_head(dir)?; + let head_file_path = dir.join(".git").join(ref_path); let finalized_hash = format!("{}\n", new_head_commit_hash); fs::write(head_file_path, finalized_hash)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_read_head_valid() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git")).unwrap(); + fs::write(dir.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + + let head = read_head(dir.path()).unwrap(); + assert_eq!(head, "refs/heads/main"); + } + + #[test] + fn test_read_head_detached() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git")).unwrap(); + fs::write( + dir.path().join(".git/HEAD"), + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n", + ) + .unwrap(); + + let result = read_head(dir.path()); + assert!(matches!(result.unwrap_err(), RefsError::DetachedHead)); + } + + #[test] + fn test_read_ref_exists() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git/refs/heads")).unwrap(); + fs::write(dir.path().join(".git/refs/heads/main"), "hash123\n").unwrap(); + + let r = read_ref("refs/heads/main", dir.path()).unwrap(); + assert_eq!(r, Some("hash123".to_string())); + } + + #[test] + fn test_read_ref_missing() { + let dir = tempdir().unwrap(); + let r = read_ref("refs/heads/missing", dir.path()).unwrap(); + assert_eq!(r, None); + } + + #[test] + fn test_update_current_ref() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git/refs/heads")).unwrap(); + fs::write(dir.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + fs::write(dir.path().join(".git/refs/heads/main"), "old_hash\n").unwrap(); + + update_current_ref("new_hash_123", dir.path()).unwrap(); + + let content = fs::read_to_string(dir.path().join(".git/refs/heads/main")).unwrap(); + assert_eq!(content.trim(), "new_hash_123"); + } +} diff --git a/src/tree.rs b/src/tree.rs index 4aeb9ba..9bf4d24 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -20,7 +20,7 @@ pub struct TreeEntry { pub hash: String, } -pub fn write_tree(path: &Path) -> Result { +pub fn write_tree(path: &Path, dir: &Path) -> Result { let entries = fs::read_dir(path)?; let mut array_of_entries: Vec = Vec::new(); @@ -33,14 +33,14 @@ pub fn write_tree(path: &Path) -> Result { if entry.path().is_file() { let content = fs::read(entry.path())?; - let object = write_object("blob", &content)?; + let object = write_object("blob", &content, dir)?; array_of_entries.push(TreeEntry { mode: "100644".to_string(), name: entry.file_name().to_string_lossy().into_owned(), hash: object, }); } else if entry.path().is_dir() { - let hashed_object = write_tree(&entry.path())?; + let hashed_object = write_tree(&entry.path(), dir)?; array_of_entries.push(TreeEntry { mode: "040000".to_string(), name: entry.file_name().to_string_lossy().into_owned(), @@ -58,5 +58,75 @@ pub fn write_tree(path: &Path) -> Result { )?; formatted_tree_entries.extend_from_slice(&hex::decode(&entry.hash)?); } - Ok(write_object("tree", &formatted_tree_entries)?) + Ok(write_object("tree", &formatted_tree_entries, dir)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::object::read_object; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_write_tree_empty_dir() { + let dir = tempdir().unwrap(); + let hash = write_tree(dir.path(), dir.path()).unwrap(); + assert_eq!(hash.len(), 40); + + let (kind, content) = read_object(&hash, dir.path()).unwrap(); + assert_eq!(kind, "tree"); + assert!( + content.is_empty(), + "Empty directory should produce empty tree content" + ); + } + + #[test] + fn test_write_tree_sorting_and_modes() { + let dir = tempdir().unwrap(); + // Arrange: Create files in reverse alphabetical order + fs::write(dir.path().join("zebra.txt"), "z").unwrap(); + fs::write(dir.path().join("apple.txt"), "a").unwrap(); + fs::create_dir(dir.path().join("banana_dir")).unwrap(); + + // Act + let hash = write_tree(dir.path(), dir.path()).unwrap(); + + // Assert: Read the raw bytes of the tree object + let (kind, content) = read_object(&hash, dir.path()).unwrap(); + assert_eq!(kind, "tree"); + + let text = String::from_utf8_lossy(&content); + + // Verify alphabetical sorting: apple -> banana_dir -> zebra + let apple_pos = text.find("apple.txt").unwrap(); + let banana_pos = text.find("banana_dir").unwrap(); + let zebra_pos = text.find("zebra.txt").unwrap(); + + assert!(apple_pos < banana_pos, "apple should come before banana"); + assert!(banana_pos < zebra_pos, "banana should come before zebra"); + + // Verify modes + assert!(text.contains("100644"), "Files should have 100644 mode"); + assert!( + text.contains("040000"), + "Directories should have 040000 mode" + ); + } + + #[test] + fn test_write_tree_ignores_git_dir() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("file.txt"), "data").unwrap(); + fs::create_dir(dir.path().join(".git")).unwrap(); + fs::write(dir.path().join(".git/HEAD"), "ref: refs/heads/main").unwrap(); + + let hash = write_tree(dir.path(), dir.path()).unwrap(); + let (_, content) = read_object(&hash, dir.path()).unwrap(); + let text = String::from_utf8_lossy(&content); + + assert!(text.contains("file.txt")); + assert!(!text.contains(".git"), "Tree should ignore .git directory"); + } } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..71e8544 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,99 @@ +use assert_cmd::Command; +use std::fs; +use tempfile::tempdir; + +#[test] +fn test_full_git_workflow() { + // 1. ARRANGE: Create an isolated, ephemeral filesystem + // `tempdir()` creates a unique folder in the OS's temp directory. + // It is automatically deleted when the `dir` variable goes out of scope. + let dir = tempdir().expect("Failed to create temp dir"); + let dir_path = dir.path(); + + // Create a dummy file to track + let file_path = dir_path.join("hello.txt"); + fs::write(&file_path, "Hello, git-rs!").expect("Failed to write dummy file"); + + // 2. ACT & ASSERT: Initialize the repository + // We use `.current_dir(dir_path)` to force the binary to run INSIDE our temp folder, + // completely isolating it from your real project files. + Command::cargo_bin("git-rs") + .unwrap() + .current_dir(dir_path) + .arg("init") + .assert() + .success(); + + // Overwrite the config to ensure deterministic author identity for the test + let config_path = dir_path.join(".git").join("config"); + fs::write( + &config_path, + "[user]\nname = \"Test User\"\nemail = \"test@example.com\"\n", + ) + .expect("Failed to write mock git config"); + + // 3. ACT & ASSERT: Hash the object + let hash_output = Command::cargo_bin("git-rs") + .unwrap() + .current_dir(dir_path) + .args(["hash-object", "-w", "hello.txt"]) + .output() + .expect("Failed to execute hash-object"); + + assert!(hash_output.status.success(), "hash-object failed"); + let hash = String::from_utf8_lossy(&hash_output.stdout); + let hash = hash.trim(); + + // Verify the hash is exactly 40 hex characters + assert_eq!(hash.len(), 40, "Object hash should be 40 chars"); + assert!( + hash.chars().all(|c| c.is_ascii_hexdigit()), + "Invalid hex character" + ); + + // 4. ACT & ASSERT: Write the tree + let tree_output = Command::cargo_bin("git-rs") + .unwrap() + .current_dir(dir_path) + .arg("write-tree") + .output() + .expect("Failed to execute write-tree"); + + assert!(tree_output.status.success(), "write-tree failed"); + let tree_hash = String::from_utf8_lossy(&tree_output.stdout) + .trim() + .to_string(); + assert_eq!(tree_hash.len(), 40); + + // 5. ACT & ASSERT: Create the commit + let commit_output = Command::cargo_bin("git-rs") + .unwrap() + .current_dir(dir_path) + .args(["commit", "-m", "Initial test commit"]) + .output() + .expect("Failed to execute commit"); + + assert!( + commit_output.status.success(), + "commit failed: {}", + String::from_utf8_lossy(&commit_output.stderr) + ); + let commit_hash = String::from_utf8_lossy(&commit_output.stdout) + .trim() + .to_string(); + assert_eq!(commit_hash.len(), 40); + + // 6. FINAL VERIFICATION: Check the Ref Update + // Prove that `update_current_ref` actually wrote the hash to the correct file + let ref_path = dir_path + .join(".git") + .join("refs") + .join("heads") + .join("main"); + let stored_ref = fs::read_to_string(ref_path).expect("Failed to read HEAD ref"); + assert_eq!( + stored_ref.trim(), + commit_hash, + "The branch reference was not updated to the new commit hash" + ); +}