1use crate::collab::manifest::{detect_repo_mode, load_manifest, RepoMode, SigningPubKey};
5use crate::crypto::{
6 decrypt, encrypt, AAD_COMMIT, AAD_MANIFEST, AAD_REPO_MANIFEST,
7};
8use crate::index::{read_index, IndexEntry};
9use crate::metadata::Commit;
10
11use crate::staged;
12use crate::store::{ObjectStoreExt, StagedStore};
13use crate::cid::ToVoidCid;
14use crate::{cid, refs, Result, VoidContext, VoidError};
15use ed25519_dalek::SigningKey;
16
17use super::types::{CommitOptions, CommitResult, SealResult};
18use super::workspace::{build_commit_stats, build_tree_manifest, seal_index, seal_workspace};
19
20fn load_commit_entries(
22 ctx: &VoidContext,
23 commit_cid: &void_crypto::CommitCid,
24) -> Result<Vec<IndexEntry>> {
25 let store = ctx.open_store()?;
26 let commit_cid = cid::from_bytes(commit_cid.as_bytes())?;
27 let (commit, reader) = ctx.load_commit(&store, &commit_cid)?;
28
29 let manifest = ctx.load_manifest(&store, &commit, &reader)?
30 .ok_or_else(|| VoidError::IntegrityError {
31 expected: "manifest_cid present on commit".into(),
32 actual: "None".into(),
33 })?;
34
35 manifest.iter()
36 .map(|me| {
37 let me = me?;
38 Ok(IndexEntry {
39 path: me.path.clone(),
40 content_hash: me.content_hash,
41 mtime_secs: 0,
42 mtime_nanos: 0,
43 size: me.length,
44 materialized: true,
45 })
46 })
47 .collect()
48}
49
50fn has_staged_changes(index: &crate::index::WorkspaceIndex, head_entries: &[IndexEntry]) -> bool {
53 if index.entries.len() != head_entries.len() {
55 return true;
56 }
57 index
59 .entries
60 .iter()
61 .zip(head_entries.iter())
62 .any(|(a, b)| a.path != b.path || a.content_hash != b.content_hash)
63}
64
65pub fn commit_workspace(opts: CommitOptions) -> Result<CommitResult> {
67 let mode = detect_repo_mode(opts.seal.ctx.paths.void_dir.as_std_path());
69 if mode == RepoMode::Collaboration {
70 if opts.seal.ctx.crypto.signing_key.is_none() {
72 return Err(VoidError::Unauthorized(
73 "Commits must be signed in collaboration mode. Configure your identity with 'void identity init'.".into()
74 ));
75 }
76
77 let signing_key = opts.seal.ctx.crypto.signing_key.as_ref()
79 .expect("signing_key presence checked by guard above");
80 let signer = get_pubkey_from_signing_key(signing_key)?;
81
82 if let Some(manifest) = load_manifest(opts.seal.ctx.paths.void_dir.as_std_path())? {
84 if manifest.find_contributor(&signer).is_none() {
85 return Err(VoidError::Unauthorized(
86 "Signer is not a contributor to this repository".into()
87 ));
88 }
89 }
90 }
91
92 let index = match read_index(opts.seal.ctx.paths.workspace_dir.as_std_path(), opts.seal.ctx.crypto.vault.index_key()?) {
94 Ok(idx) => Some(idx),
95 Err(VoidError::NotFound(_)) => None,
96 Err(err) => return Err(err),
97 };
98
99 let head_entries = match &opts.parent_cid {
101 Some(parent_cid) if !opts.foreign_parent => {
102 load_commit_entries(&opts.seal.ctx, parent_cid)?
103 }
104 _ => Vec::new(),
105 };
106
107 if let Some(ref idx) = index {
110 if !has_staged_changes(idx, &head_entries) {
111 return Err(VoidError::NothingToCommit(
112 "no changes staged for commit (use 'void add' to stage files)".to_string(),
113 ));
114 }
115 }
116 let (content_key, key_nonce) = opts.seal.ctx.crypto.vault.derive_commit_key()?;
121
122 let mut seal_opts = opts.seal.clone();
125 seal_opts.content_key = Some(content_key);
126
127 if let Some(ref parent_cid) = opts.parent_cid {
130 if !opts.foreign_parent {
131 let parent_store = opts.seal.ctx.open_store()?;
132 let parent_cid_obj = cid::from_bytes(parent_cid.as_bytes())?;
133 let parent_encrypted: void_crypto::EncryptedCommit = parent_store.get_blob(&parent_cid_obj)?;
134 let (_, parent_reader) = opts.seal.ctx.open_commit(&parent_encrypted)?;
135 seal_opts.parent_content_key = Some(parent_reader.content_key().clone());
136 }
137 }
138
139 let t0 = std::time::Instant::now();
141 let seal_result = match index {
142 Some(idx) => seal_index(&seal_opts, &idx.entries)?,
143 None => seal_workspace(seal_opts.clone())?,
144 };
145 let t_seal = t0.elapsed();
146
147 if seal_result.stats.files_sealed == 0 && head_entries.is_empty() {
149 return Err(VoidError::NothingToCommit(
150 "nothing to commit: working tree is empty".to_string(),
151 ));
152 }
153
154 let new_file_count = seal_result.stats.files_sealed;
157 let parent_file_count = head_entries.len();
158 const MIN_FILES_FOR_GUARD: usize = 10;
159 const MAX_DELETION_RATIO: f64 = 0.7; if parent_file_count >= MIN_FILES_FOR_GUARD && !opts.allow_data_loss {
162 let ratio = new_file_count as f64 / parent_file_count as f64;
163 if ratio < MAX_DELETION_RATIO {
164 return Err(VoidError::DataLossGuard {
165 parent_files: parent_file_count,
166 new_files: new_file_count,
167 deleted_pct: ((1.0 - ratio) * 100.0) as u8,
168 });
169 }
170 }
171
172 let SealResult {
173 metadata_cid,
174 shard_cids,
175 stats,
176 index_entries,
177 ..
178 } = seal_result.clone();
179
180 let t_guard = t0.elapsed();
181
182 let staged_store = StagedStore::new(opts.seal.ctx.paths.void_dir.as_std_path())?;
184
185 let manifest = build_tree_manifest(&seal_result)?;
187 let manifest_stats = build_commit_stats(&manifest);
188
189 let result_total_files = manifest_stats.total_files;
191 let result_total_bytes = manifest_stats.total_bytes;
192
193 let manifest_encrypted = encrypt(content_key.as_bytes(), manifest.as_bytes(), AAD_MANIFEST)?;
195
196 let manifest_void_cid = staged_store.stage_or_discard(&manifest_encrypted)?;
198 let manifest_cid = void_crypto::ManifestCid::from_bytes(cid::to_bytes(&manifest_void_cid));
199
200 let timestamp = std::time::SystemTime::now()
201 .duration_since(std::time::UNIX_EPOCH)
202 .unwrap_or_default()
203 .as_millis() as u64;
204
205 let mut commit = Commit::new(
206 opts.parent_cid.clone(),
207 metadata_cid.clone(),
208 timestamp,
209 opts.message,
210 );
211
212 commit.manifest_cid = Some(manifest_cid.clone());
214 commit.stats = Some(manifest_stats);
215
216 if mode == RepoMode::Collaboration {
218 if let Ok(Some(collab_manifest)) = load_manifest(opts.seal.ctx.paths.void_dir.as_std_path()) {
219 let manifest_json = serde_json::to_vec(&collab_manifest)
220 .map_err(|e| VoidError::Serialization(format!("repo manifest: {}", e)))?;
221 let rm_encrypted = encrypt(content_key.as_bytes(), &manifest_json, AAD_REPO_MANIFEST)?;
222 let rm_cid = staged_store.stage_or_discard(&rm_encrypted)?;
223 commit.repo_manifest_cid = Some(void_crypto::RepoManifestCid::from_bytes(cid::to_bytes(&rm_cid)));
224 }
225 }
226
227 if let Some(ref signing_key) = opts.seal.ctx.crypto.signing_key {
229 commit.sign(signing_key);
230 }
231
232 let commit_bytes = crate::support::cbor_to_vec(&commit)?;
233 let commit_encrypted =
235 opts.seal.ctx.crypto.vault.seal_commit_with_nonce(&commit_bytes, &key_nonce)?;
236
237 let commit_cid = staged_store.stage_blob_or_discard(&commit_encrypted)?;
239
240 let manifest_void_cid = manifest_cid.to_void_cid()?;
245 match staged_store.get(&manifest_void_cid) {
246 Ok(encrypted) => {
247 if let Err(e) = decrypt(content_key.as_bytes(), &encrypted, AAD_MANIFEST) {
248 let _ = staged_store.discard_all();
249 return Err(VoidError::Io(std::io::Error::new(
250 std::io::ErrorKind::Other,
251 format!("staged manifest verification failed: {}", e),
252 )));
253 }
254 }
255 Err(e) => {
256 let _ = staged_store.discard_all();
257 return Err(VoidError::Io(std::io::Error::new(
258 std::io::ErrorKind::Other,
259 format!("staged manifest read failed: {}", e),
260 )));
261 }
262 }
263
264 if let Some(ref rm_cid_typed) = commit.repo_manifest_cid {
266 let rm_cid = rm_cid_typed.to_void_cid()?;
267 match staged_store.get(&rm_cid) {
268 Ok(encrypted) => {
269 if let Err(e) = decrypt(content_key.as_bytes(), &encrypted, AAD_REPO_MANIFEST) {
270 let _ = staged_store.discard_all();
271 return Err(VoidError::Io(std::io::Error::new(
272 std::io::ErrorKind::Other,
273 format!("staged repo manifest verification failed: {}", e),
274 )));
275 }
276 }
277 Err(e) => {
278 let _ = staged_store.discard_all();
279 return Err(VoidError::Io(std::io::Error::new(
280 std::io::ErrorKind::Other,
281 format!("staged repo manifest read failed: {}", e),
282 )));
283 }
284 }
285 }
286
287 match staged_store.get(&commit_cid) {
289 Ok(encrypted) => {
290 if let Err(e) = opts.seal.ctx.decrypt(&encrypted, AAD_COMMIT) {
291 let _ = staged_store.discard_all();
292 return Err(VoidError::Io(std::io::Error::new(
293 std::io::ErrorKind::Other,
294 format!("staged commit verification failed: {}", e),
295 )));
296 }
297 }
298 Err(e) => {
299 let _ = staged_store.discard_all();
300 return Err(VoidError::Io(std::io::Error::new(
301 std::io::ErrorKind::Other,
302 format!("staged commit read failed: {}", e),
303 )));
304 }
305 }
306
307 let t_verify = t0.elapsed();
308
309 if let Err(e) = staged_store.publish_all() {
311 let _ = staged_store.discard_all();
312 return Err(e);
313 }
314
315 let commit_cid_typed = void_crypto::CommitCid::from_bytes(cid::to_bytes(&commit_cid));
316
317 let t_publish = t0.elapsed();
318
319 crate::index::write_index(
320 opts.seal.ctx.paths.workspace_dir.as_std_path(),
321 opts.seal.ctx.crypto.vault.index_key()?,
322 Some(commit_cid_typed.clone()),
323 index_entries,
324 )?;
325 let t_index = t0.elapsed();
326
327 let _ = staged::clear_all_staged(opts.seal.ctx.paths.workspace_dir.as_std_path());
330 let t_prune = t0.elapsed();
331
332 eprintln!("[commit timing] seal={:?} guard={:?} verify={:?} publish={:?} index={:?} prune={:?}",
333 t_seal, t_guard - t_seal, t_verify - t_guard, t_publish - t_verify,
334 t_index - t_publish, t_prune - t_index);
335
336 match refs::read_head(&opts.seal.ctx.paths.workspace_dir)? {
337 Some(refs::HeadRef::Symbolic(branch)) => {
338 refs::write_branch(&opts.seal.ctx.paths.void_dir, &branch, &commit_cid_typed)?;
339 }
340 Some(refs::HeadRef::Detached(_)) => {
341 refs::write_head(
342 &opts.seal.ctx.paths.workspace_dir,
343 &refs::HeadRef::Detached(commit_cid_typed.clone()),
344 )?;
345 }
346 None => {
347 let default_branch = "trunk".to_string();
348 refs::write_head(&opts.seal.ctx.paths.workspace_dir, &refs::HeadRef::Symbolic(default_branch.clone()))?;
349 refs::write_branch(&opts.seal.ctx.paths.void_dir, &default_branch, &commit_cid_typed)?;
350 }
351 }
352
353 Ok(CommitResult {
354 commit_cid: commit_cid_typed,
355 metadata_cid,
356 shard_cids,
357 stats,
358 manifest_cid: Some(manifest_cid),
359 total_files: Some(result_total_files),
360 total_bytes: Some(result_total_bytes),
361 repo_manifest_cid: commit.repo_manifest_cid.clone(),
362 })
363}
364
365fn get_pubkey_from_signing_key(signing_key: &SigningKey) -> Result<SigningPubKey> {
371 Ok(SigningPubKey::from_bytes(signing_key.verifying_key().to_bytes()))
372}
373