diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..895303f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy, llvm-tools-preview + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-deny + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov + + - name: Check formatting + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Test + run: cargo test --all-targets + + - name: Supply-chain checks + run: cargo deny check + + - name: Coverage + run: cargo llvm-cov --all-targets --fail-under-lines 80 --summary-only diff --git a/src/backend/mod.rs b/src/backend/mod.rs index b3f4e54..53b23e0 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -19,9 +19,9 @@ pub fn from_config(cfg: &BigstoreConfig) -> Result { let s = store::build_object_store(&cfg.backend)?; Ok(Backend::ObjectStore(Arc::from(s))) } - BackendConfig::Rclone { remote } => Ok(Backend::Rclone(rclone::RcloneBackend::new( - remote.clone(), - ))), + BackendConfig::Rclone { remote } => { + Ok(Backend::Rclone(rclone::RcloneBackend::new(remote.clone()))) + } BackendConfig::Local { path } => { let s = store::build_local_store(path)?; Ok(Backend::ObjectStore(Arc::from(s))) diff --git a/src/config.rs b/src/config.rs index 0baebc4..7a6beef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,14 +41,10 @@ pub enum BackendConfig { }, #[serde(rename = "rclone")] - Rclone { - remote: String, - }, + Rclone { remote: String }, #[serde(rename = "local")] - Local { - path: String, - }, + Local { path: String }, } impl BigstoreConfig { @@ -124,8 +120,7 @@ impl BigstoreConfig { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read {}", path.display()))?; // Layout is validated during deserialization — invalid templates fail here - toml::from_str(&content) - .with_context(|| format!("failed to parse {}", path.display())) + toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display())) } /// Find and load config from a repo root. @@ -137,12 +132,12 @@ impl BigstoreConfig { } let legacy_path = repo_root.join(".bigstore"); if legacy_path.exists() { - eprintln!("note: using legacy .bigstore config; run `git bigstore migrate-config` to upgrade"); + eprintln!( + "note: using legacy .bigstore config; run `git bigstore migrate-config` to upgrade" + ); return Self::load(&legacy_path); } - anyhow::bail!( - "no bigstore config found (looked for .bigstore.toml and .bigstore)" - ) + anyhow::bail!("no bigstore config found (looked for .bigstore.toml and .bigstore)") } pub fn bucket_prefix(&self) -> &str { @@ -167,7 +162,11 @@ impl BigstoreConfig { /// Build the remote object key using the configured layout. /// Safe: Layout is validated, Hexdigest is validated. /// Returns Err if the layout doesn't support the given hash function. - pub fn remote_object_key(&self, hexdigest: &Hexdigest, hash_fn: HashFunction) -> Result { + pub fn remote_object_key( + &self, + hexdigest: &Hexdigest, + hash_fn: HashFunction, + ) -> Result { let key = self.layout.object_key(hexdigest, hash_fn)?; let bucket_prefix = match &self.backend { @@ -224,10 +223,7 @@ mod tests { let cfg = BigstoreConfig::from_url("t3://my-bucket", None).unwrap(); match &cfg.backend { BackendConfig::S3 { endpoint, .. } => { - assert_eq!( - endpoint.as_deref(), - Some("https://fly.storage.tigris.dev") - ); + assert_eq!(endpoint.as_deref(), Some("https://fly.storage.tigris.dev")); } _ => panic!("expected S3"), } @@ -238,7 +234,10 @@ mod tests { let cfg = BigstoreConfig::from_url("s3://bucket/data", None).unwrap(); let d = test_digest(); let key = cfg.remote_object_key(&d, HashFunction::Sha256).unwrap(); - assert_eq!(key, format!("data/files/sha256/{}/{}", d.prefix(), d.rest())); + assert_eq!( + key, + format!("data/files/sha256/{}/{}", d.prefix(), d.rest()) + ); } #[test] diff --git a/src/dvc.rs b/src/dvc.rs index 735c0d3..1494a02 100644 --- a/src/dvc.rs +++ b/src/dvc.rs @@ -286,11 +286,7 @@ mod tests { fn parse_dir_manifest_rejects_empty_relpath() { let tmp = tempfile::NamedTempFile::new().unwrap(); let md5 = "aa".repeat(16); - std::fs::write( - tmp.path(), - format!(r#"[{{"md5":"{md5}","relpath":""}}]"#), - ) - .unwrap(); + std::fs::write(tmp.path(), format!(r#"[{{"md5":"{md5}","relpath":""}}]"#)).unwrap(); assert!(parse_dir_manifest(tmp.path()).is_err()); } @@ -298,11 +294,7 @@ mod tests { fn parse_dir_manifest_rejects_dot_relpath() { let tmp = tempfile::NamedTempFile::new().unwrap(); let md5 = "aa".repeat(16); - std::fs::write( - tmp.path(), - format!(r#"[{{"md5":"{md5}","relpath":"."}}]"#), - ) - .unwrap(); + std::fs::write(tmp.path(), format!(r#"[{{"md5":"{md5}","relpath":"."}}]"#)).unwrap(); let err = parse_dir_manifest(tmp.path()).unwrap_err(); assert!(err.to_string().contains("."), "{err}"); } @@ -310,11 +302,7 @@ mod tests { #[test] fn parse_dir_manifest_rejects_bad_md5() { let tmp = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - tmp.path(), - r#"[{"md5":"not-valid","relpath":"file.bin"}]"#, - ) - .unwrap(); + std::fs::write(tmp.path(), r#"[{"md5":"not-valid","relpath":"file.bin"}]"#).unwrap(); assert!(parse_dir_manifest(tmp.path()).is_err()); } } diff --git a/src/filter.rs b/src/filter.rs index 20f2b2d..6580ad3 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -49,8 +49,8 @@ pub fn clean() -> Result<()> { tmp.flush()?; let hex_str = hex::encode(hasher.finalize()); - let hexdigest = Hexdigest::new(&hex_str, hash_fn) - .context("internal error: sha256 produced invalid hex")?; + let hexdigest = + Hexdigest::new(&hex_str, hash_fn).context("internal error: sha256 produced invalid hex")?; let dest = cache::object_path(&git_dir, &hexdigest, hash_fn); if let Some(parent) = dest.parent() { diff --git a/src/git.rs b/src/git.rs index 39aeb74..55f25d9 100644 --- a/src/git.rs +++ b/src/git.rs @@ -88,14 +88,18 @@ impl FilterConfig { } // Partial presence - let clean = clean.ok_or_else(|| anyhow::anyhow!( - "filter.bigstore.smudge is set but filter.bigstore.clean is missing.\n\ + let clean = clean.ok_or_else(|| { + anyhow::anyhow!( + "filter.bigstore.smudge is set but filter.bigstore.clean is missing.\n\ Fix: git config filter.bigstore.clean \"git-bigstore filter-clean\"" - ))?; - let smudge = smudge.ok_or_else(|| anyhow::anyhow!( - "filter.bigstore.clean is set but filter.bigstore.smudge is missing.\n\ + ) + })?; + let smudge = smudge.ok_or_else(|| { + anyhow::anyhow!( + "filter.bigstore.clean is set but filter.bigstore.smudge is missing.\n\ Fix: git config filter.bigstore.smudge \"git-bigstore filter-smudge\"" - ))?; + ) + })?; // Required must be "true" match required.as_deref() { @@ -111,16 +115,20 @@ impl FilterConfig { } // Command shape: must end with "filter-clean" / "filter-smudge" - let clean_bin = clean.strip_suffix(" filter-clean").ok_or_else(|| anyhow::anyhow!( - "filter.bigstore.clean has unexpected format: {clean:?}\n\ + let clean_bin = clean.strip_suffix(" filter-clean").ok_or_else(|| { + anyhow::anyhow!( + "filter.bigstore.clean has unexpected format: {clean:?}\n\ Expected: \" filter-clean\"\n\ Fix: git config filter.bigstore.clean \"git-bigstore filter-clean\"" - ))?; - let smudge_bin = smudge.strip_suffix(" filter-smudge").ok_or_else(|| anyhow::anyhow!( - "filter.bigstore.smudge has unexpected format: {smudge:?}\n\ + ) + })?; + let smudge_bin = smudge.strip_suffix(" filter-smudge").ok_or_else(|| { + anyhow::anyhow!( + "filter.bigstore.smudge has unexpected format: {smudge:?}\n\ Expected: \" filter-smudge\"\n\ Fix: git config filter.bigstore.smudge \"git-bigstore filter-smudge\"" - ))?; + ) + })?; // Same binary prefix anyhow::ensure!( diff --git a/src/lfs_adapter.rs b/src/lfs_adapter.rs index c1722fb..56e857f 100644 --- a/src/lfs_adapter.rs +++ b/src/lfs_adapter.rs @@ -15,8 +15,8 @@ //! 1. .bigstore.toml (if present) //! 2. git config bigstore-lfs.url (fallback for LFS-only repos) -use anyhow::{Context, Result}; use crate::{backend, config, git, transfer, types}; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::io::{BufRead, BufReader, Write}; use std::path::Path; @@ -326,8 +326,8 @@ pub fn run() -> Result<()> { continue; } - let event: Event = - serde_json::from_str(&line).with_context(|| format!("invalid JSON from LFS: {line}"))?; + let event: Event = serde_json::from_str(&line) + .with_context(|| format!("invalid JSON from LFS: {line}"))?; match event.event.as_str() { "init" => match load_config() { diff --git a/src/main.rs b/src/main.rs index 7ca1dcb..09ed160 100644 --- a/src/main.rs +++ b/src/main.rs @@ -288,9 +288,7 @@ fn cmd_migrate_config(force: bool) -> Result<()> { ); if toml_path.exists() && !force { - anyhow::bail!( - ".bigstore.toml already exists. Use --force to overwrite." - ); + anyhow::bail!(".bigstore.toml already exists. Use --force to overwrite."); } // Load from legacy, save as toml (validates + normalizes) @@ -341,12 +339,28 @@ fn cmd_log(paths: &[String]) -> Result<()> { // For all others (including merges): diff against first parent explicitly let diff_output = if is_root { Command::new("git") - .args(["diff-tree", "--root", "-r", "-M", "-C", "--name-status", commit]) + .args([ + "diff-tree", + "--root", + "-r", + "-M", + "-C", + "--name-status", + commit, + ]) .output()? } else { let parent = format!("{commit}~1"); Command::new("git") - .args(["diff-tree", "-r", "-M", "-C", "--name-status", &parent, commit]) + .args([ + "diff-tree", + "-r", + "-M", + "-C", + "--name-status", + &parent, + commit, + ]) .output()? }; if !diff_output.status.success() { @@ -419,8 +433,12 @@ fn cmd_log(paths: &[String]) -> Result<()> { // Rename where bigstore tracking was removed ('R', Some(_), None) => ChangeKind::RenamedDeleted, // Pure rename (same content hash) - ('R', _, _) if old_pointer.as_ref().map(|p| &p.hexdigest) - == new_pointer.as_ref().map(|p| &p.hexdigest) => ChangeKind::Renamed, + ('R', _, _) + if old_pointer.as_ref().map(|p| &p.hexdigest) + == new_pointer.as_ref().map(|p| &p.hexdigest) => + { + ChangeKind::Renamed + } // Everything else: content change _ => ChangeKind::Modified, }; @@ -442,7 +460,9 @@ fn cmd_log(paths: &[String]) -> Result<()> { let meta_output = Command::new("git") .args(["log", "-1", "--format=%h %ai %s", commit]) .output()?; - let meta = String::from_utf8_lossy(&meta_output.stdout).trim().to_string(); + let meta = String::from_utf8_lossy(&meta_output.stdout) + .trim() + .to_string(); if found_any { println!(); @@ -461,31 +481,55 @@ fn cmd_log(paths: &[String]) -> Result<()> { match c.kind { ChangeKind::Added => { if let Some(p) = &c.new_pointer { - println!(" {symbol} {} {}:{}", c.path, p.hash_fn, short_hash(&p.hexdigest)); + println!( + " {symbol} {} {}:{}", + c.path, + p.hash_fn, + short_hash(&p.hexdigest) + ); } } ChangeKind::Deleted => { if let Some(p) = &c.old_pointer { - println!(" {symbol} {} {}:{}", c.path, p.hash_fn, short_hash(&p.hexdigest)); + println!( + " {symbol} {} {}:{}", + c.path, + p.hash_fn, + short_hash(&p.hexdigest) + ); } } ChangeKind::RenamedAdded | ChangeKind::Copied => { let old = c.old_path.as_deref().unwrap_or("?"); if let Some(p) = &c.new_pointer { - println!(" {symbol} {old} -> {} {}:{}", c.path, p.hash_fn, short_hash(&p.hexdigest)); + println!( + " {symbol} {old} -> {} {}:{}", + c.path, + p.hash_fn, + short_hash(&p.hexdigest) + ); } } ChangeKind::RenamedDeleted => { let old = c.old_path.as_deref().unwrap_or("?"); if let Some(p) = &c.old_pointer { - println!(" {symbol} {old} -> {} {}:{}", c.path, p.hash_fn, short_hash(&p.hexdigest)); + println!( + " {symbol} {old} -> {} {}:{}", + c.path, + p.hash_fn, + short_hash(&p.hexdigest) + ); } } ChangeKind::Modified => { - let old_desc = c.old_pointer.as_ref() + let old_desc = c + .old_pointer + .as_ref() .map(|p| format!("{}:{}", p.hash_fn, short_hash(&p.hexdigest))) .unwrap_or_else(|| "(not a pointer)".to_string()); - let new_desc = c.new_pointer.as_ref() + let new_desc = c + .new_pointer + .as_ref() .map(|p| format!("{}:{}", p.hash_fn, short_hash(&p.hexdigest))) .unwrap_or_else(|| "(not a pointer)".to_string()); let path_str = if let Some(op) = &c.old_path { @@ -498,7 +542,12 @@ fn cmd_log(paths: &[String]) -> Result<()> { ChangeKind::Renamed => { let old = c.old_path.as_deref().unwrap_or("?"); if let Some(p) = &c.new_pointer { - println!(" {symbol} {old} -> {} {}:{}", c.path, p.hash_fn, short_hash(&p.hexdigest)); + println!( + " {symbol} {old} -> {} {}:{}", + c.path, + p.hash_fn, + short_hash(&p.hexdigest) + ); } } } @@ -545,7 +594,11 @@ impl CatFileBatch { let stdin = child.stdin.take().expect("stdin was piped"); let stdout = BufReader::new(child.stdout.take().expect("stdout was piped")); - Ok(Self { child, stdin: Some(stdin), stdout }) + Ok(Self { + child, + stdin: Some(stdin), + stdout, + }) } /// Read a blob and try to parse it as a bigstore pointer. @@ -615,7 +668,7 @@ impl Drop for CatFileBatch { fn short_hash(hexdigest: &types::Hexdigest) -> String { let s = hexdigest.to_string(); if s.len() > 12 { - format!("{}..{}", &s[..6], &s[s.len()-6..]) + format!("{}..{}", &s[..6], &s[s.len() - 6..]) } else { s } @@ -769,10 +822,7 @@ fn cmd_import_dvc_dir( for c in &conflicts { eprintln!(" {dest_root}/{c}"); } - anyhow::bail!( - "{} destination file(s) already exist", - conflicts.len() - ); + anyhow::bail!("{} destination file(s) already exist", conflicts.len()); } } @@ -815,8 +865,7 @@ fn cmd_import_dvc_dir( // Restore real content so working tree has data, not pointer text. // The clean filter will convert back to pointer on `git add`. - let cache_path = - cache::object_path(&git_dir, hexdigest, types::HashFunction::Md5); + let cache_path = cache::object_path(&git_dir, hexdigest, types::HashFunction::Md5); if cache_path.exists() { cache::copy_to_working_tree(&cache_path, &dest_path)?; } @@ -838,7 +887,11 @@ fn cmd_import_dvc_dir( for (path, err) in &failed { eprintln!("FAILED: {path} — {err}"); } - anyhow::bail!("{} of {} entries failed", failed.len(), failed.len() + total as usize); + anyhow::bail!( + "{} of {} entries failed", + failed.len(), + failed.len() + total as usize + ); } eprintln!(); @@ -912,7 +965,10 @@ fn resolve_dvc_cache(source_path: &Path) -> Result { /// Reject absolute paths and path traversal. fn validate_relative_path(label: &str, p: &str) -> Result<()> { let path = Path::new(p); - anyhow::ensure!(!path.is_absolute(), "{label} must be a relative path: {p:?}"); + anyhow::ensure!( + !path.is_absolute(), + "{label} must be a relative path: {p:?}" + ); anyhow::ensure!( !path .components() diff --git a/src/transfer.rs b/src/transfer.rs index a274151..a1a9553 100644 --- a/src/transfer.rs +++ b/src/transfer.rs @@ -125,10 +125,15 @@ async fn download_one( // Step 4: Download to temp file, hashing as we stream pb.set_message(format!("{path} (downloading)")); - let verified_path = - download_and_verify(store, &remote_key, git_dir, pointer.hash_fn, &pointer.hexdigest) - .await - .with_context(|| format!("downloading {path}"))?; + let verified_path = download_and_verify( + store, + &remote_key, + git_dir, + pointer.hash_fn, + &pointer.hexdigest, + ) + .await + .with_context(|| format!("downloading {path}"))?; // Step 6 happened inside download_and_verify (atomic persist) // Step 7: Copy to working tree @@ -171,8 +176,7 @@ async fn download_and_verify( } Backend::Rclone(_) => { // For rclone, download to temp first, then hash - let tmp_dl = - tempfile::NamedTempFile::new_in(cache::cache_dir(git_dir))?; + let tmp_dl = tempfile::NamedTempFile::new_in(cache::cache_dir(git_dir))?; backend::download(store, remote_key, tmp_dl.path()).await?; let mut file = std::fs::File::open(tmp_dl.path())?; @@ -363,9 +367,17 @@ pub async fn pull(tracked: &[(String, String)], concurrency: usize) -> Result Result Result { hasher.update(&buf[..n]); } let hex_str = hasher.finalize_hex(); - Hexdigest::new(&hex_str, hash_fn) - .context("internal error: hasher produced invalid hex") + Hexdigest::new(&hex_str, hash_fn).context("internal error: hasher produced invalid hex") } fn progress_bar(total: u64) -> ProgressBar { diff --git a/src/types.rs b/src/types.rs index 363552d..5f7dfdd 100644 --- a/src/types.rs +++ b/src/types.rs @@ -73,7 +73,6 @@ impl Hexdigest { pub fn rest(&self) -> &str { &self.0[2..] } - } impl fmt::Display for Hexdigest { @@ -164,12 +163,12 @@ impl Layout { Update layout in .bigstore.toml to: files/{{hash_fn}}/{{prefix}}/{{rest}}" ); } - Ok(self.0 + Ok(self + .0 .replace("{hash_fn}", hash_fn.as_str()) .replace("{prefix}", hexdigest.prefix()) .replace("{rest}", hexdigest.rest())) } - } /// DVC-compatible default layout. @@ -187,13 +186,18 @@ impl fmt::Display for Layout { } impl Serialize for Layout { - fn serialize(&self, serializer: S) -> std::result::Result { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { self.0.serialize(serializer) } } impl<'de> Deserialize<'de> for Layout { - fn deserialize>(deserializer: D) -> std::result::Result { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { let s = String::deserialize(deserializer)?; Layout::new(&s).map_err(serde::de::Error::custom) } diff --git a/tests/integration.rs b/tests/integration.rs index e1aa3b0..a1733cf 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -73,7 +73,7 @@ impl TestRepo { std::fs::create_dir_all(&storage_dir).unwrap(); // Init git repo - git(&repo_dir, &["init"]); + git(&repo_dir, &["init", "-b", "main"]); git(&repo_dir, &["config", "user.email", "test@test.com"]); git(&repo_dir, &["config", "user.name", "Test"]); @@ -139,9 +139,15 @@ fn init_creates_config_and_sets_git_filters() { // Git filters should be configured (we override to full path in TestRepo::new, // so just check they contain "filter-clean" / "filter-smudge") let clean = git(&t.repo_dir, &["config", "filter.bigstore.clean"]); - assert!(clean.contains("filter-clean"), "clean filter not set: {clean}"); + assert!( + clean.contains("filter-clean"), + "clean filter not set: {clean}" + ); let smudge = git(&t.repo_dir, &["config", "filter.bigstore.smudge"]); - assert!(smudge.contains("filter-smudge"), "smudge filter not set: {smudge}"); + assert!( + smudge.contains("filter-smudge"), + "smudge filter not set: {smudge}" + ); } #[test] @@ -152,20 +158,32 @@ fn init_preserves_existing_filter_config() { // Capture the current custom filter paths. let custom_clean = git(&t.repo_dir, &["config", "filter.bigstore.clean"]); let custom_smudge = git(&t.repo_dir, &["config", "filter.bigstore.smudge"]); - assert!(custom_clean.contains('/'), "should be a full path: {custom_clean}"); + assert!( + custom_clean.contains('/'), + "should be a full path: {custom_clean}" + ); // Re-run init — should NOT clobber the custom filter paths let storage_url = format!("local://{}", t.storage_dir.display()); let output = bigstore(&t.repo_dir, &["init", &storage_url]); assert!(output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("preserved"), "should mention filter was preserved: {stderr}"); + assert!( + stderr.contains("preserved"), + "should mention filter was preserved: {stderr}" + ); // Verify filters are still the custom paths let clean_after = git(&t.repo_dir, &["config", "filter.bigstore.clean"]); let smudge_after = git(&t.repo_dir, &["config", "filter.bigstore.smudge"]); - assert_eq!(clean_after, custom_clean, "clean filter should be preserved"); - assert_eq!(smudge_after, custom_smudge, "smudge filter should be preserved"); + assert_eq!( + clean_after, custom_clean, + "clean filter should be preserved" + ); + assert_eq!( + smudge_after, custom_smudge, + "smudge filter should be preserved" + ); } /// Helper: create a bare git repo + storage dir for filter config tests. @@ -176,7 +194,7 @@ fn bare_test_repo() -> (tempfile::TempDir, PathBuf, PathBuf) { std::fs::create_dir_all(&repo_dir).unwrap(); std::fs::create_dir_all(&storage_dir).unwrap(); - git(&repo_dir, &["init"]); + git(&repo_dir, &["init", "-b", "main"]); git(&repo_dir, &["config", "user.email", "test@test.com"]); git(&repo_dir, &["config", "user.name", "Test"]); @@ -189,7 +207,11 @@ fn init_rejects_clean_without_smudge() { git( &repo_dir, - &["config", "filter.bigstore.clean", "git-bigstore filter-clean"], + &[ + "config", + "filter.bigstore.clean", + "git-bigstore filter-clean", + ], ); let storage_url = format!("local://{}", storage_dir.display()); @@ -208,7 +230,11 @@ fn init_rejects_smudge_without_clean() { git( &repo_dir, - &["config", "filter.bigstore.smudge", "git-bigstore filter-smudge"], + &[ + "config", + "filter.bigstore.smudge", + "git-bigstore filter-smudge", + ], ); let storage_url = format!("local://{}", storage_dir.display()); @@ -227,20 +253,28 @@ fn init_rejects_mismatched_filter_binaries() { git( &repo_dir, - &["config", "filter.bigstore.clean", "/usr/bin/bigstore filter-clean"], - ); - git( - &repo_dir, - &["config", "filter.bigstore.smudge", "/opt/bin/bigstore filter-smudge"], + &[ + "config", + "filter.bigstore.clean", + "/usr/bin/bigstore filter-clean", + ], ); git( &repo_dir, - &["config", "filter.bigstore.required", "true"], + &[ + "config", + "filter.bigstore.smudge", + "/opt/bin/bigstore filter-smudge", + ], ); + git(&repo_dir, &["config", "filter.bigstore.required", "true"]); let storage_url = format!("local://{}", storage_dir.display()); let output = bigstore(&repo_dir, &["init", &storage_url]); - assert!(!output.status.success(), "should fail with mismatched binaries"); + assert!( + !output.status.success(), + "should fail with mismatched binaries" + ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("different binaries"), @@ -254,17 +288,28 @@ fn init_rejects_missing_required() { git( &repo_dir, - &["config", "filter.bigstore.clean", "git-bigstore filter-clean"], + &[ + "config", + "filter.bigstore.clean", + "git-bigstore filter-clean", + ], ); git( &repo_dir, - &["config", "filter.bigstore.smudge", "git-bigstore filter-smudge"], + &[ + "config", + "filter.bigstore.smudge", + "git-bigstore filter-smudge", + ], ); // Deliberately not setting required let storage_url = format!("local://{}", storage_dir.display()); let output = bigstore(&repo_dir, &["init", &storage_url]); - assert!(!output.status.success(), "should fail with missing required"); + assert!( + !output.status.success(), + "should fail with missing required" + ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("required"), @@ -380,8 +425,15 @@ fn large_file_multipart_round_trip() { bigstore_ok(&t.repo_dir, &["pull"]); let restored = t.read_file("big.bin"); - assert_eq!(restored.len(), content.len(), "size mismatch after multipart round-trip"); - assert_eq!(restored, content, "content mismatch after multipart round-trip"); + assert_eq!( + restored.len(), + content.len(), + "size mismatch after multipart round-trip" + ); + assert_eq!( + restored, content, + "content mismatch after multipart round-trip" + ); } #[test] @@ -399,7 +451,7 @@ fn push_is_idempotent() { // First push bigstore_ok(&t.repo_dir, &["push"]); // Second push — should skip (already uploaded) - let output = bigstore(& t.repo_dir, &["push"]); + let output = bigstore(&t.repo_dir, &["push"]); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("already up to date") || stderr.contains("0 file(s) uploaded"), @@ -548,10 +600,7 @@ fn multiple_files_tracked() { t.write_file("a.bin", b"file a\n"); t.write_file("b.bin", b"file b\n"); t.write_file("assets/model.dat", b"model data\n"); - git( - &t.repo_dir, - &["add", "a.bin", "b.bin", "assets/model.dat"], - ); + git(&t.repo_dir, &["add", "a.bin", "b.bin", "assets/model.dat"]); git(&t.repo_dir, &["commit", "-m", "add files"]); // Push all @@ -593,8 +642,11 @@ fn ref_creates_md5_pointer_from_dvc_file() { // Create a .dvc file t.write_file( "model.bin.dvc", - format!("outs:\n- md5: {md5_hash}\n size: {}\n path: model.bin\n", content.len()) - .as_bytes(), + format!( + "outs:\n- md5: {md5_hash}\n size: {}\n path: model.bin\n", + content.len() + ) + .as_bytes(), ); // Run ref command @@ -602,10 +654,14 @@ fn ref_creates_md5_pointer_from_dvc_file() { // Content is restored from cache — working tree has real data, not pointer text let restored = t.read_file("model.bin"); - assert_eq!(restored, content, "working tree should have real content after ref"); + assert_eq!( + restored, content, + "working tree should have real content after ref" + ); // Bigstore cache should have the object - let bs_cache = t.repo_dir + let bs_cache = t + .repo_dir .join(".git/bigstore/objects/md5") .join(&md5_hash[..2]) .join(&md5_hash[2..]); @@ -654,24 +710,25 @@ fn ref_imports_from_dvc_cache_with_verification() { // Create .dvc file t.write_file( "data.bin.dvc", - format!("outs:\n- md5: {md5_hash}\n size: {}\n path: data.bin\n", content.len()) - .as_bytes(), + format!( + "outs:\n- md5: {md5_hash}\n size: {}\n path: data.bin\n", + content.len() + ) + .as_bytes(), ); // Run ref — should import from DVC cache let output = bigstore(&t.repo_dir, &["ref", "data.bin.dvc", "data.bin"]); let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "ref should succeed: {stderr}" - ); + assert!(output.status.success(), "ref should succeed: {stderr}"); assert!( stderr.contains("Imported from DVC cache"), "should report DVC cache import: {stderr}" ); // Verify bigstore cache now has the object - let bs_cache = t.repo_dir + let bs_cache = t + .repo_dir .join(".git/bigstore/objects/md5") .join(shard) .join(rest); @@ -860,7 +917,10 @@ fn legacy_layout_sha256_works_md5_rejected() { bigstore_ok(&t.repo_dir, &["pull"]); let restored = t.read_file("data.bin"); - assert_eq!(restored, original_content, "sha256 should work with legacy layout"); + assert_eq!( + restored, original_content, + "sha256 should work with legacy layout" + ); // MD5 pointer should fail with layout-migration error let content = b"md5 content\n"; @@ -912,15 +972,30 @@ fn log_shows_bigstore_file_history() { let output = bigstore_ok(&t.repo_dir, &["log"]); // Should show both commits - assert!(output.contains("update model v2"), "should show v2 commit: {output}"); - assert!(output.contains("add model v1"), "should show v1 commit: {output}"); + assert!( + output.contains("update model v2"), + "should show v2 commit: {output}" + ); + assert!( + output.contains("add model v1"), + "should show v1 commit: {output}" + ); // Should show the file path - assert!(output.contains("model.bin"), "should show file path: {output}"); + assert!( + output.contains("model.bin"), + "should show file path: {output}" + ); // Should show + for add and ~ for modify - assert!(output.contains("+ model.bin"), "should show + for add: {output}"); - assert!(output.contains("~ model.bin"), "should show ~ for modify: {output}"); + assert!( + output.contains("+ model.bin"), + "should show + for add: {output}" + ); + assert!( + output.contains("~ model.bin"), + "should show ~ for modify: {output}" + ); } #[test] @@ -961,7 +1036,10 @@ fn log_detects_renames() { let output = bigstore_ok(&t.repo_dir, &["log"]); // Should show R symbol with both paths - assert!(output.contains("R old.bin -> new.bin"), "should show R with old -> new: {output}"); + assert!( + output.contains("R old.bin -> new.bin"), + "should show R with old -> new: {output}" + ); } #[test] @@ -980,8 +1058,14 @@ fn log_shows_delete_as_minus() { git(&t.repo_dir, &["commit", "-m", "delete temp"]); let output = bigstore_ok(&t.repo_dir, &["log"]); - assert!(output.contains("- temp.bin"), "should show - for delete: {output}"); - assert!(output.contains("+ temp.bin"), "should also show + for the add: {output}"); + assert!( + output.contains("- temp.bin"), + "should show - for delete: {output}" + ); + assert!( + output.contains("+ temp.bin"), + "should also show + for the add: {output}" + ); } #[test] @@ -1005,7 +1089,10 @@ fn log_shows_changes_from_merge_commits() { // Switch back to main, merge feature git(&t.repo_dir, &["checkout", "main"]); - git(&t.repo_dir, &["merge", "feature", "--no-ff", "-m", "merge feature"]); + git( + &t.repo_dir, + &["merge", "feature", "--no-ff", "-m", "merge feature"], + ); let output = bigstore_ok(&t.repo_dir, &["log"]); @@ -1041,8 +1128,14 @@ fn log_ignores_non_bigstore_files() { let output = bigstore_ok(&t.repo_dir, &["log"]); // Should show data.bin but not readme.txt - assert!(output.contains("data.bin"), "should show data.bin: {output}"); - assert!(!output.contains("readme.txt"), "should NOT show readme.txt: {output}"); + assert!( + output.contains("data.bin"), + "should show data.bin: {output}" + ); + assert!( + !output.contains("readme.txt"), + "should NOT show readme.txt: {output}" + ); } #[test] @@ -1057,14 +1150,34 @@ fn log_nonpointer_to_pointer_shows_add() { // Now add .gitattributes to track *.bin, re-add the file so the // clean filter converts it to a pointer t.write_file(".gitattributes", b"*.bin filter=bigstore\n"); - bigstore_ok(&t.repo_dir, &["init", &format!("local://{}", t.storage_dir.display())]); + bigstore_ok( + &t.repo_dir, + &["init", &format!("local://{}", t.storage_dir.display())], + ); // Override filter paths for test binary let bin = env!("CARGO_BIN_EXE_git-bigstore"); - git(&t.repo_dir, &["config", "filter.bigstore.clean", &format!("{bin} filter-clean")]); - git(&t.repo_dir, &["config", "filter.bigstore.smudge", &format!("{bin} filter-smudge")]); + git( + &t.repo_dir, + &[ + "config", + "filter.bigstore.clean", + &format!("{bin} filter-clean"), + ], + ); + git( + &t.repo_dir, + &[ + "config", + "filter.bigstore.smudge", + &format!("{bin} filter-smudge"), + ], + ); t.write_file("model.bin", b"plain content\n"); // same content, but now filtered - git(&t.repo_dir, &["add", ".gitattributes", ".bigstore.toml", "model.bin"]); + git( + &t.repo_dir, + &["add", ".gitattributes", ".bigstore.toml", "model.bin"], + ); git(&t.repo_dir, &["commit", "-m", "convert to bigstore"]); let output = bigstore_ok(&t.repo_dir, &["log"]); @@ -1112,7 +1225,7 @@ fn log_root_commit_with_bigstore_file() { std::fs::create_dir_all(&repo_dir).unwrap(); std::fs::create_dir_all(&storage_dir).unwrap(); - git(&repo_dir, &["init"]); + git(&repo_dir, &["init", "-b", "main"]); git(&repo_dir, &["config", "user.email", "test@test.com"]); git(&repo_dir, &["config", "user.name", "Test"]); @@ -1120,13 +1233,30 @@ fn log_root_commit_with_bigstore_file() { bigstore_ok(&repo_dir, &["init", &storage_url]); let bin = env!("CARGO_BIN_EXE_git-bigstore"); - git(&repo_dir, &["config", "filter.bigstore.clean", &format!("{bin} filter-clean")]); - git(&repo_dir, &["config", "filter.bigstore.smudge", &format!("{bin} filter-smudge")]); + git( + &repo_dir, + &[ + "config", + "filter.bigstore.clean", + &format!("{bin} filter-clean"), + ], + ); + git( + &repo_dir, + &[ + "config", + "filter.bigstore.smudge", + &format!("{bin} filter-smudge"), + ], + ); // First (root) commit includes a bigstore file std::fs::write(repo_dir.join(".gitattributes"), b"*.bin filter=bigstore\n").unwrap(); std::fs::write(repo_dir.join("initial.bin"), b"root commit data\n").unwrap(); - git(&repo_dir, &["add", ".gitattributes", ".bigstore.toml", "initial.bin"]); + git( + &repo_dir, + &["add", ".gitattributes", ".bigstore.toml", "initial.bin"], + ); git(&repo_dir, &["commit", "-m", "root with bigstore file"]); let output = bigstore_ok(&repo_dir, &["log"]); @@ -1158,11 +1288,7 @@ fn log_copy_shows_c_with_both_paths() { // To trigger copy detection with -C, the source must also be modified // in the same changeset. So: copy original.bin -> copy.bin AND modify // original.bin in the same commit. - std::fs::copy( - t.repo_dir.join("original.bin"), - t.repo_dir.join("copy.bin"), - ) - .unwrap(); + std::fs::copy(t.repo_dir.join("original.bin"), t.repo_dir.join("copy.bin")).unwrap(); t.write_file("original.bin", b"modified original for copy test\n"); git(&t.repo_dir, &["add", "copy.bin", "original.bin"]); git(&t.repo_dir, &["commit", "-m", "copy and modify"]); @@ -1242,8 +1368,14 @@ fn dvc_ls_lists_dir_entries() { setup_dvc_dir(&t, "models.dvc", files); let output = bigstore_ok(&t.repo_dir, &["dvc-ls", "models.dvc"]); - assert!(output.contains("weights/model.pt"), "should list model.pt: {output}"); - assert!(output.contains("exports/out.onnx"), "should list out.onnx: {output}"); + assert!( + output.contains("weights/model.pt"), + "should list model.pt: {output}" + ); + assert!( + output.contains("exports/out.onnx"), + "should list out.onnx: {output}" + ); } #[test] @@ -1253,8 +1385,11 @@ fn dvc_ls_rejects_single_file_dvc() { let md5_hash = format!("{:x}", md5::Md5::digest(content)); t.write_file( "data.dvc", - format!("outs:\n- md5: {md5_hash}\n size: {}\n path: data.bin\n", content.len()) - .as_bytes(), + format!( + "outs:\n- md5: {md5_hash}\n size: {}\n path: data.bin\n", + content.len() + ) + .as_bytes(), ); let output = bigstore(&t.repo_dir, &["dvc-ls", "data.dvc"]); @@ -1276,12 +1411,12 @@ fn import_dvc_dir_imports_multiple_files() { ]; let info = setup_dvc_dir(&t, "models.dvc", files); - let output = bigstore(&t.repo_dir, &["import-dvc-dir", "models.dvc", "imported-models"]); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "import should succeed: {stderr}" + let output = bigstore( + &t.repo_dir, + &["import-dvc-dir", "models.dvc", "imported-models"], ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "import should succeed: {stderr}"); // Content is restored from cache — working tree has real data, not pointer text let expected_contents: std::collections::HashMap<&str, &[u8]> = files.iter().copied().collect(); @@ -1290,16 +1425,23 @@ fn import_dvc_dir_imports_multiple_files() { assert!(file_path.exists(), "file should exist at {relpath}"); let actual = std::fs::read(&file_path).unwrap(); let expected = expected_contents[relpath.as_str()]; - assert_eq!(actual, expected, "working tree should have real content for {relpath}"); + assert_eq!( + actual, expected, + "working tree should have real content for {relpath}" + ); } // Verify bigstore cache has the objects for (md5, _relpath) in &info { - let bs_cache = t.repo_dir + let bs_cache = t + .repo_dir .join(".git/bigstore/objects/md5") .join(&md5[..2]) .join(&md5[2..]); - assert!(bs_cache.exists(), "object {md5} should be in bigstore cache"); + assert!( + bs_cache.exists(), + "object {md5} should be in bigstore cache" + ); } // Verify suggested .gitattributes pattern @@ -1379,9 +1521,7 @@ fn import_dvc_dir_fails_on_missing_cache_blob() { #[test] fn import_dvc_dir_fails_on_existing_destination() { let t = TestRepo::new(); - let files: &[(&str, &[u8])] = &[ - ("model.pt", b"model data"), - ]; + let files: &[(&str, &[u8])] = &[("model.pt", b"model data")]; setup_dvc_dir(&t, "models.dvc", files); // Create a conflicting file at the destination @@ -1403,21 +1543,25 @@ fn import_dvc_dir_fails_on_existing_destination() { #[test] fn import_dvc_dir_force_overwrites() { let t = TestRepo::new(); - let files: &[(&str, &[u8])] = &[ - ("model.pt", b"model data"), - ]; + let files: &[(&str, &[u8])] = &[("model.pt", b"model data")]; setup_dvc_dir(&t, "models.dvc", files); // Create conflicting file t.write_file("dest/model.pt", b"old content"); - let output = bigstore(&t.repo_dir, &["import-dvc-dir", "models.dvc", "dest", "--force"]); + let output = bigstore( + &t.repo_dir, + &["import-dvc-dir", "models.dvc", "dest", "--force"], + ); let stderr = String::from_utf8_lossy(&output.stderr); assert!(output.status.success(), "force should succeed: {stderr}"); // Content is restored — working tree has real data, not old content let content = std::fs::read(t.repo_dir.join("dest/model.pt")).unwrap(); - assert_eq!(content, b"model data", "should have restored content, not old data"); + assert_eq!( + content, b"model data", + "should have restored content, not old data" + ); } #[test] @@ -1436,7 +1580,10 @@ fn import_dvc_dir_filters_by_pattern() { &["import-dvc-dir", "models.dvc", "dest", "exports/*.onnx"], ); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(output.status.success(), "filtered import should succeed: {stderr}"); + assert!( + output.status.success(), + "filtered import should succeed: {stderr}" + ); // Only exports/*.onnx should be imported assert!(t.repo_dir.join("dest/exports/out.onnx").exists()); @@ -1451,7 +1598,10 @@ fn dvc_ls_rejects_path_traversal() { let output = bigstore(&t.repo_dir, &["dvc-ls", "../../../etc/passwd"]); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains(".."), "should reject path traversal: {stderr}"); + assert!( + stderr.contains(".."), + "should reject path traversal: {stderr}" + ); let output = bigstore(&t.repo_dir, &["dvc-ls", "/etc/passwd"]); assert!(!output.status.success()); @@ -1471,7 +1621,10 @@ fn import_dvc_dir_rejects_source_path_traversal() { ); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains(".."), "should reject source traversal: {stderr}"); + assert!( + stderr.contains(".."), + "should reject source traversal: {stderr}" + ); } #[test] @@ -1486,15 +1639,16 @@ fn import_dvc_dir_rejects_dest_path_traversal() { ); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains(".."), "should reject dest traversal: {stderr}"); + assert!( + stderr.contains(".."), + "should reject dest traversal: {stderr}" + ); } #[test] fn import_dvc_dir_suggests_directory_scoped_gitattributes() { let t = TestRepo::new(); - let files: &[(&str, &[u8])] = &[ - ("file.bin", b"data"), - ]; + let files: &[(&str, &[u8])] = &[("file.bin", b"data")]; setup_dvc_dir(&t, "models.dvc", files); let output = bigstore(&t.repo_dir, &["import-dvc-dir", "models.dvc", "my-models"]); @@ -1571,7 +1725,10 @@ fn lfs_adapter_downloads_and_verifies() { .lines() .find(|l| l.contains("\"event\":\"complete\"")) .unwrap_or_else(|| panic!("no complete event: {stdout}")); - assert!(!complete.contains("\"error\""), "good object should not error: {complete}"); + assert!( + !complete.contains("\"error\""), + "good object should not error: {complete}" + ); let v: serde_json::Value = serde_json::from_str(complete).unwrap(); assert!( @@ -1645,8 +1802,15 @@ fn lfs_adapter_uploads_verified() { .lines() .find(|l| l.contains("\"event\":\"complete\"")) .unwrap_or_else(|| panic!("no complete event: {stdout}")); - assert!(!complete.contains("\"error\""), "valid upload should not error: {complete}"); - assert_eq!(storage_object_count(&t), 1, "verified upload should write one object"); + assert!( + !complete.contains("\"error\""), + "valid upload should not error: {complete}" + ); + assert_eq!( + storage_object_count(&t), + 1, + "verified upload should write one object" + ); } #[test]