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 @@
+[](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