use camino::Utf8PathBuf;
use serde::Serialize;
use std::path::Path;
use void_core::cid;
use void_core::cid::ToVoidCid;
use void_core::metadata::ShardMap;
use void_core::pipeline::{commit_workspace, CommitOptions, SealOptions};
use void_core::refs;
use void_core::shard::PaddingStrategy;
use crate::context::{build_void_context, find_void_dir, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug)]
pub struct CommitArgs {
pub message: String,
pub sign: bool,
pub no_sign: bool,
pub target_shard_size: Option<u64>,
pub max_shard_size: Option<u64>,
pub mmap_threshold: Option<u64>,
pub padding: Option<String>,
pub allow_data_loss: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitOutput {
pub commit: String,
pub parent: Option<String>,
pub message: String,
pub signed: bool,
pub files: u64,
pub bytes: u64,
pub files_changed: u64,
pub shards: u64,
pub shards_changed: u64,
pub stats: CommitStats,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitStats {
pub bytes_read: u64,
pub bytes_compressed: u64,
pub bytes_encrypted: u64,
pub walk_ms: u64,
pub compress_ms: u64,
pub total_ms: u64,
}
fn parse_padding_strategy(s: &str) -> Result<PaddingStrategy, CliError> {
match s.to_lowercase().as_str() {
"none" => Ok(PaddingStrategy::None),
"power2" => Ok(PaddingStrategy::PowerOfTwo),
"buckets" => Ok(PaddingStrategy::Buckets),
other => other
.parse::<usize>()
.map(PaddingStrategy::Fixed)
.map_err(|_| {
CliError::invalid_args(format!(
"Invalid padding strategy '{}'. Use: none, power2, buckets, or a number",
other
))
}),
}
}
fn validate_signing_flags(
sign: bool,
no_sign: bool,
has_key: bool,
) -> Result<bool, CliError> {
if sign && no_sign {
return Err(CliError::invalid_args(
"--sign and --no-sign are mutually exclusive",
));
}
if no_sign {
return Ok(false); }
if sign && !has_key {
return Err(CliError::not_found(
"No identity found. Run 'void identity init' first to create signing keys.",
));
}
Ok(has_key) }
pub fn run(cwd: &Path, args: CommitArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("commit", opts, |ctx| {
ctx.progress("Preparing commit...");
let mut void_ctx = build_void_context(cwd)?;
let keep_signing = validate_signing_flags(
args.sign,
args.no_sign,
void_ctx.crypto.signing_key.is_some(),
)?;
if !keep_signing {
void_ctx.crypto.signing_key = None;
}
let is_signed = void_ctx.crypto.signing_key.is_some();
if let Some(target) = args.target_shard_size {
void_ctx.seal.target_shard_size = target;
if args.max_shard_size.is_none() {
void_ctx.seal.max_shard_size = target.saturating_mul(3) / 2;
}
}
if let Some(max) = args.max_shard_size {
void_ctx.seal.max_shard_size = max;
}
if let Some(threshold) = args.mmap_threshold {
void_ctx.seal.mmap_threshold = threshold;
}
if let Some(ref s) = args.padding {
void_ctx.seal.padding = parse_padding_strategy(s)?;
}
let void_dir = find_void_dir(cwd)?;
let void_dir_utf8 = Utf8PathBuf::try_from(void_dir)
.map_err(|e| CliError::internal(format!("invalid void_dir path: {}", e)))?;
let parent_cid = refs::resolve_head(&void_dir_utf8).map_err(void_err_to_cli)?;
let parent_cid_str = parent_cid.as_ref().map(|commit_cid| {
cid::from_bytes(commit_cid.as_bytes())
.map(|c| c.to_string())
.unwrap_or_else(|_| hex::encode(commit_cid.as_bytes()))
});
ctx.verbose(format!(
"Parent CID: {}",
parent_cid_str.as_deref().unwrap_or("none (initial commit)")
));
let seal_opts = SealOptions {
ctx: void_ctx,
shard_map: ShardMap::new(64), content_key: None,
parent_content_key: None,
};
let commit_opts = CommitOptions {
seal: seal_opts,
message: args.message.clone(),
parent_cid,
allow_data_loss: args.allow_data_loss,
foreign_parent: false,
};
ctx.progress("Creating commit...");
let result = commit_workspace(commit_opts).map_err(void_err_to_cli)?;
let commit_cid_str = result.commit_cid.to_cid_string();
if !ctx.use_json() {
let short_cid = &commit_cid_str[..12.min(commit_cid_str.len())];
ctx.info(format!("Created commit {}...", short_cid));
ctx.info(format!(" Files: {}", result.stats.files_sealed));
ctx.info(format!(" Size: {} bytes", result.stats.bytes_read));
if result.stats.files_changed > 0 {
ctx.info(format!(" Changed: {} files", result.stats.files_changed));
}
if is_signed {
ctx.info(" Signed: yes".to_string());
}
}
Ok(CommitOutput {
commit: commit_cid_str,
parent: parent_cid_str,
message: args.message,
signed: is_signed,
files: result.stats.files_sealed as u64,
bytes: result.stats.bytes_read as u64,
files_changed: result.stats.files_changed as u64,
shards: result.stats.shards_created as u64,
shards_changed: result.stats.shards_changed as u64,
stats: CommitStats {
bytes_read: result.stats.bytes_read as u64,
bytes_compressed: result.stats.bytes_compressed as u64,
bytes_encrypted: result.stats.bytes_encrypted as u64,
walk_ms: result.stats.walk_ms as u64,
compress_ms: result.stats.compress_ms as u64,
total_ms: result.stats.total_ms as u64,
},
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::CliOptions;
use std::fs;
use tempfile::tempdir;
use void_core::crypto;
fn default_opts() -> CliOptions {
CliOptions {
human: true,
..Default::default()
}
}
fn default_args(message: &str) -> CommitArgs {
CommitArgs {
message: message.to_string(),
sign: false,
no_sign: false,
target_shard_size: None,
max_shard_size: None,
mmap_threshold: None,
padding: None,
allow_data_loss: false,
}
}
fn setup_test_repo() -> (tempfile::TempDir, std::path::PathBuf, tempfile::TempDir, crate::context::VoidHomeGuard) {
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir_all(void_dir.join("objects")).unwrap();
let key = crypto::generate_key();
let home = tempdir().unwrap();
let guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());
let repo_secret = hex::encode(crypto::generate_key());
fs::write(
void_dir.join("config.json"),
format!(r#"{{"repoSecret": "{}"}}"#, repo_secret),
)
.unwrap();
fs::write(dir.path().join("test.txt"), "hello world").unwrap();
(dir, void_dir, home, guard)
}
#[test]
fn test_commit_creates_commit() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let result = run(dir.path(), default_args("test commit"), &default_opts());
assert!(result.is_ok());
let head_path = void_dir.join("HEAD");
assert!(head_path.exists() || void_dir.join("refs/heads/trunk").exists());
}
#[test]
fn test_commit_not_initialized() {
let dir = tempdir().unwrap();
let result = run(dir.path(), default_args("test commit"), &default_opts());
assert!(result.is_err());
}
#[test]
fn test_commit_default_message() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = CommitArgs {
message: "commit".to_string(),
..default_args("")
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_commit_sign_with_identity_succeeds() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = CommitArgs {
sign: true,
..default_args("test commit")
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_commit_no_sign_errors_in_collab_mode() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = CommitArgs {
no_sign: true,
..default_args("test commit")
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_parse_padding_strategy() {
assert!(matches!(
parse_padding_strategy("none"),
Ok(PaddingStrategy::None)
));
assert!(matches!(
parse_padding_strategy("NONE"),
Ok(PaddingStrategy::None)
));
assert!(matches!(
parse_padding_strategy("power2"),
Ok(PaddingStrategy::PowerOfTwo)
));
assert!(matches!(
parse_padding_strategy("buckets"),
Ok(PaddingStrategy::Buckets)
));
assert!(matches!(
parse_padding_strategy("4096"),
Ok(PaddingStrategy::Fixed(4096))
));
assert!(parse_padding_strategy("invalid").is_err());
}
}