ipfrs_storage/
vcs.rs

1//! Version Control System for Differentiable Storage
2//!
3//! Provides Git-like version control for tensor models and gradients:
4//! - Commit tracking with parent links (DAG structure)
5//! - Branch and tag management
6//! - Checkout to specific commits
7//! - Merge support for collaborative training
8//!
9//! This enables reproducible model states and collaborative training workflows.
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use ipfrs_storage::{VersionControl, Author, SledBlockStore, BlockStoreConfig};
15//! use ipfrs_core::Block;
16//! use bytes::Bytes;
17//! use std::sync::Arc;
18//! use std::collections::HashMap;
19//! use std::path::PathBuf;
20//!
21//! # async fn example() -> ipfrs_core::Result<()> {
22//! // Create a block store
23//! let config = BlockStoreConfig {
24//!     path: PathBuf::from(".ipfrs/vcs"),
25//!     cache_size: 100 * 1024 * 1024,
26//! };
27//! let store = Arc::new(SledBlockStore::new(config)?);
28//!
29//! // Initialize version control
30//! let vcs = VersionControl::new(store.clone());
31//!
32//! // Store model v1 and create initial commit
33//! let model_v1 = Block::new(Bytes::from("model weights v1"))?;
34//! store.put(&model_v1).await?;
35//!
36//! let author = Author {
37//!     name: "AI Researcher".to_string(),
38//!     email: "researcher@example.com".to_string(),
39//! };
40//!
41//! let commit1 = vcs.commit(
42//!     *model_v1.cid(),
43//!     "Initial model".to_string(),
44//!     author.clone(),
45//!     HashMap::new(),
46//! ).await?;
47//!
48//! // Train model, create v2, and commit
49//! let model_v2 = Block::new(Bytes::from("model weights v2"))?;
50//! store.put(&model_v2).await?;
51//!
52//! let commit2 = vcs.commit(
53//!     *model_v2.cid(),
54//!     "After 100 epochs".to_string(),
55//!     author,
56//!     HashMap::new(),
57//! ).await?;
58//!
59//! // Checkout to previous version
60//! let model_cid = vcs.checkout(&commit1).await?;
61//! let previous_model = store.get(&model_cid).await?;
62//!
63//! // View commit history
64//! let history = vcs.log(&commit2, 10).await?;
65//! for commit in history {
66//!     println!("{}: {}", commit.timestamp, commit.message);
67//! }
68//! # Ok(())
69//! # }
70//! ```
71
72use crate::traits::BlockStore;
73use bytes::Bytes;
74use ipfrs_core::{Block, Cid, Error, Result};
75use serde::{Deserialize, Deserializer, Serialize, Serializer};
76use std::collections::HashMap;
77use std::sync::Arc;
78use std::time::{SystemTime, UNIX_EPOCH};
79
80// Custom serialization for Cid
81fn serialize_cid<S>(cid: &Cid, serializer: S) -> std::result::Result<S::Ok, S::Error>
82where
83    S: Serializer,
84{
85    serializer.serialize_bytes(&cid.to_bytes())
86}
87
88fn deserialize_cid<'de, D>(deserializer: D) -> std::result::Result<Cid, D::Error>
89where
90    D: Deserializer<'de>,
91{
92    let bytes: Vec<u8> = Deserialize::deserialize(deserializer)?;
93    Cid::try_from(bytes).map_err(serde::de::Error::custom)
94}
95
96fn serialize_cid_vec<S>(cids: &[Cid], serializer: S) -> std::result::Result<S::Ok, S::Error>
97where
98    S: Serializer,
99{
100    use serde::ser::SerializeSeq;
101    let mut seq = serializer.serialize_seq(Some(cids.len()))?;
102    for cid in cids {
103        seq.serialize_element(&cid.to_bytes())?;
104    }
105    seq.end()
106}
107
108fn deserialize_cid_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<Cid>, D::Error>
109where
110    D: Deserializer<'de>,
111{
112    let bytes_vec: Vec<Vec<u8>> = Deserialize::deserialize(deserializer)?;
113    bytes_vec
114        .into_iter()
115        .map(|bytes| Cid::try_from(bytes).map_err(serde::de::Error::custom))
116        .collect()
117}
118
119/// IPLD schema for a commit in the version control system
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121pub struct Commit {
122    /// CID of the commit (computed from serialized commit, not included in serialization)
123    #[serde(skip)]
124    pub cid: Option<Cid>,
125
126    /// CIDs of parent commits (empty for initial commit)
127    #[serde(
128        serialize_with = "serialize_cid_vec",
129        deserialize_with = "deserialize_cid_vec"
130    )]
131    pub parents: Vec<Cid>,
132
133    /// CID of the root block this commit points to (e.g., model weights)
134    #[serde(serialize_with = "serialize_cid", deserialize_with = "deserialize_cid")]
135    pub root: Cid,
136
137    /// Commit message describing the changes
138    pub message: String,
139
140    /// Author of the commit
141    pub author: Author,
142
143    /// Unix timestamp when commit was created
144    pub timestamp: u64,
145
146    /// Optional metadata (e.g., training config, hyperparameters)
147    pub metadata: HashMap<String, String>,
148}
149
150/// Author information for a commit
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct Author {
153    /// Author name
154    pub name: String,
155    /// Author email
156    pub email: String,
157}
158
159impl Commit {
160    /// Create a new commit
161    pub fn new(
162        parents: Vec<Cid>,
163        root: Cid,
164        message: String,
165        author: Author,
166        metadata: HashMap<String, String>,
167    ) -> Self {
168        let timestamp = SystemTime::now()
169            .duration_since(UNIX_EPOCH)
170            .unwrap()
171            .as_secs();
172
173        Self {
174            cid: None,
175            parents,
176            root,
177            message,
178            author,
179            timestamp,
180            metadata,
181        }
182    }
183
184    /// Serialize commit to bytes and compute CID
185    pub fn finalize(&mut self) -> Result<Cid> {
186        let bytes = oxicode::serde::encode_to_vec(self, oxicode::config::standard())
187            .map_err(|e| Error::Serialization(format!("Failed to serialize commit: {e}")))?;
188
189        let block = Block::new(Bytes::from(bytes))?;
190        let cid = *block.cid();
191        self.cid = Some(cid);
192        Ok(cid)
193    }
194
195    /// Create a commit from a block
196    pub fn from_block(block: &Block) -> Result<Self> {
197        let mut commit: Commit =
198            oxicode::serde::decode_owned_from_slice(block.data(), oxicode::config::standard())
199                .map(|(v, _)| v)
200                .map_err(|e| Error::Serialization(format!("Failed to deserialize commit: {e}")))?;
201        commit.cid = Some(*block.cid());
202        Ok(commit)
203    }
204
205    /// Convert commit to a block for storage
206    pub fn to_block(&self) -> Result<Block> {
207        let bytes = oxicode::serde::encode_to_vec(self, oxicode::config::standard())
208            .map_err(|e| Error::Serialization(format!("Failed to serialize commit: {e}")))?;
209        Block::new(Bytes::from(bytes))
210    }
211
212    /// Check if this is an initial commit (no parents)
213    pub fn is_initial(&self) -> bool {
214        self.parents.is_empty()
215    }
216
217    /// Get a reference to the commit CID (panics if not finalized)
218    pub fn cid(&self) -> &Cid {
219        self.cid.as_ref().expect("Commit not finalized")
220    }
221}
222
223/// Reference to a commit (branch or tag)
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
225pub struct Ref {
226    /// Name of the reference (e.g., "main", "dev", "v1.0")
227    pub name: String,
228    /// CID of the commit this ref points to
229    #[serde(serialize_with = "serialize_cid", deserialize_with = "deserialize_cid")]
230    pub commit: Cid,
231    /// Type of reference
232    pub ref_type: RefType,
233}
234
235/// Type of reference
236#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
237pub enum RefType {
238    /// Mutable branch pointer
239    Branch,
240    /// Immutable tag pointer
241    Tag,
242}
243
244impl Ref {
245    /// Create a new branch reference
246    pub fn branch(name: String, commit: Cid) -> Self {
247        Self {
248            name,
249            commit,
250            ref_type: RefType::Branch,
251        }
252    }
253
254    /// Create a new tag reference
255    pub fn tag(name: String, commit: Cid) -> Self {
256        Self {
257            name,
258            commit,
259            ref_type: RefType::Tag,
260        }
261    }
262
263    /// Convert ref to a block for storage
264    pub fn to_block(&self) -> Result<Block> {
265        let bytes = oxicode::serde::encode_to_vec(self, oxicode::config::standard())
266            .map_err(|e| Error::Serialization(format!("Failed to serialize ref: {e}")))?;
267        Block::new(Bytes::from(bytes))
268    }
269
270    /// Create a ref from a block
271    pub fn from_block(block: &Block) -> Result<Self> {
272        oxicode::serde::decode_owned_from_slice(block.data(), oxicode::config::standard())
273            .map(|(v, _)| v)
274            .map_err(|e| Error::Serialization(format!("Failed to deserialize ref: {e}")))
275    }
276}
277
278/// Version Control System for managing commits, branches, and tags
279pub struct VersionControl<S: BlockStore> {
280    /// Underlying block store
281    store: Arc<S>,
282    /// Current branch name
283    current_branch: parking_lot::RwLock<String>,
284    /// HEAD pointer (current commit CID)
285    head: parking_lot::RwLock<Option<Cid>>,
286    /// In-memory refs cache (ref name -> commit CID)
287    refs_cache: dashmap::DashMap<String, Cid>,
288}
289
290/// Merge strategy for combining branches
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum MergeStrategy {
293    /// Fast-forward merge (only when target is ancestor of source)
294    FastForward,
295    /// Three-way merge (creates merge commit)
296    ThreeWay,
297    /// Ours (keep current branch's changes on conflict)
298    Ours,
299    /// Theirs (accept incoming branch's changes on conflict)
300    Theirs,
301}
302
303/// Result of a merge operation
304#[derive(Debug, Clone, PartialEq)]
305pub enum MergeResult {
306    /// Fast-forward merge succeeded
307    FastForward { target: Cid },
308    /// Merge commit created
309    MergeCommit { commit: Cid },
310    /// Conflicts detected (contains conflicting paths)
311    Conflicts { conflicts: Vec<String> },
312}
313
314impl<S: BlockStore> VersionControl<S> {
315    /// Create a new version control system
316    pub fn new(store: Arc<S>) -> Self {
317        Self {
318            store,
319            current_branch: parking_lot::RwLock::new("main".to_string()),
320            head: parking_lot::RwLock::new(None),
321            refs_cache: dashmap::DashMap::new(),
322        }
323    }
324
325    /// List all refs
326    pub fn list_refs(&self) -> Vec<(String, Cid)> {
327        self.refs_cache
328            .iter()
329            .map(|entry| (entry.key().clone(), *entry.value()))
330            .collect()
331    }
332
333    /// Create a new commit
334    ///
335    /// # Arguments
336    /// * `root` - CID of the root block (e.g., model weights)
337    /// * `message` - Commit message
338    /// * `author` - Author information
339    /// * `metadata` - Optional metadata
340    pub async fn commit(
341        &self,
342        root: Cid,
343        message: String,
344        author: Author,
345        metadata: HashMap<String, String>,
346    ) -> Result<Cid> {
347        // Get parent commits (current HEAD)
348        let parents = if let Some(head) = *self.head.read() {
349            vec![head]
350        } else {
351            vec![] // Initial commit
352        };
353
354        // Create and finalize commit
355        let mut commit = Commit::new(parents, root, message, author, metadata);
356        let commit_cid = commit.finalize()?;
357
358        // Store commit block
359        let commit_block = commit.to_block()?;
360        self.store.put(&commit_block).await?;
361
362        // Update HEAD
363        *self.head.write() = Some(commit_cid);
364
365        // Update current branch ref
366        let branch_name = self.current_branch.read().clone();
367        self.update_ref(&branch_name, commit_cid, RefType::Branch)
368            .await?;
369
370        Ok(commit_cid)
371    }
372
373    /// Checkout to a specific commit
374    ///
375    /// Returns the root CID of the commit (e.g., model weights to load)
376    pub async fn checkout(&self, commit_cid: &Cid) -> Result<Cid> {
377        // Load commit
378        let commit_block = self
379            .store
380            .get(commit_cid)
381            .await?
382            .ok_or_else(|| Error::NotFound(format!("Commit not found: {commit_cid}")))?;
383
384        let commit = Commit::from_block(&commit_block)?;
385
386        // Update HEAD to this commit
387        *self.head.write() = Some(*commit_cid);
388
389        // Return root CID for application to load
390        Ok(commit.root)
391    }
392
393    /// Checkout to a branch or tag
394    pub async fn checkout_ref(&self, ref_name: &str) -> Result<Cid> {
395        // Load ref
396        let ref_obj = self.get_ref(ref_name).await?;
397
398        // Update current branch if it's a branch
399        if ref_obj.ref_type == RefType::Branch {
400            *self.current_branch.write() = ref_name.to_string();
401        }
402
403        // Checkout to the commit
404        self.checkout(&ref_obj.commit).await
405    }
406
407    /// Create a new branch at the current HEAD
408    pub async fn create_branch(&self, branch_name: &str) -> Result<()> {
409        let head = self
410            .head
411            .read()
412            .ok_or_else(|| Error::Storage("No HEAD commit".to_string()))?;
413
414        self.update_ref(branch_name, head, RefType::Branch).await
415    }
416
417    /// Create a new tag at the current HEAD
418    pub async fn create_tag(&self, tag_name: &str) -> Result<()> {
419        let head = self
420            .head
421            .read()
422            .ok_or_else(|| Error::Storage("No HEAD commit".to_string()))?;
423
424        self.update_ref(tag_name, head, RefType::Tag).await
425    }
426
427    /// Update a reference (branch or tag)
428    async fn update_ref(&self, name: &str, commit: Cid, ref_type: RefType) -> Result<()> {
429        // Store in cache
430        self.refs_cache.insert(name.to_string(), commit);
431
432        // Also persist the ref as a block for durability
433        let ref_obj = Ref {
434            name: name.to_string(),
435            commit,
436            ref_type,
437        };
438
439        let ref_block = ref_obj.to_block()?;
440        self.store.put(&ref_block).await?;
441
442        Ok(())
443    }
444
445    /// Get a reference by name
446    #[allow(clippy::unused_async)]
447    async fn get_ref(&self, name: &str) -> Result<Ref> {
448        // Check cache first
449        if let Some(commit_cid) = self.refs_cache.get(name) {
450            // Determine ref type based on naming convention
451            let ref_type = if name.starts_with("refs/tags/") || name.contains("/tags/") {
452                RefType::Tag
453            } else {
454                RefType::Branch
455            };
456
457            return Ok(Ref {
458                name: name.to_string(),
459                commit: *commit_cid,
460                ref_type,
461            });
462        }
463
464        Err(Error::NotFound(format!("Ref not found: {name}")))
465    }
466
467    /// Get the current HEAD commit CID
468    pub fn head(&self) -> Option<Cid> {
469        *self.head.read()
470    }
471
472    /// Get the current branch name
473    pub fn current_branch(&self) -> String {
474        self.current_branch.read().clone()
475    }
476
477    /// Get commit history (walk the DAG backwards)
478    pub async fn log(&self, commit_cid: &Cid, limit: usize) -> Result<Vec<Commit>> {
479        let mut commits = Vec::new();
480        let mut current = Some(*commit_cid);
481
482        while let Some(cid) = current {
483            if commits.len() >= limit {
484                break;
485            }
486
487            // Load commit
488            let commit_block = self
489                .store
490                .get(&cid)
491                .await?
492                .ok_or_else(|| Error::NotFound(format!("Commit not found: {cid}")))?;
493
494            let commit = Commit::from_block(&commit_block)?;
495
496            // Move to parent
497            current = commit.parents.first().copied();
498
499            commits.push(commit);
500        }
501
502        Ok(commits)
503    }
504
505    /// Get the underlying store
506    pub fn store(&self) -> &Arc<S> {
507        &self.store
508    }
509
510    /// Find common ancestor between two commits
511    pub async fn find_common_ancestor(&self, commit1: &Cid, commit2: &Cid) -> Result<Option<Cid>> {
512        // Get all ancestors of commit1
513        let mut ancestors1 = std::collections::HashSet::new();
514        let mut queue = vec![*commit1];
515
516        while let Some(cid) = queue.pop() {
517            if !ancestors1.insert(cid) {
518                continue; // Already visited
519            }
520
521            let block = self
522                .store
523                .get(&cid)
524                .await?
525                .ok_or_else(|| Error::NotFound(format!("Commit not found: {cid}")))?;
526
527            let commit = Commit::from_block(&block)?;
528            queue.extend(commit.parents.iter().copied());
529        }
530
531        // Walk commit2's ancestors until we find one in ancestors1
532        let mut queue = vec![*commit2];
533        let mut visited = std::collections::HashSet::new();
534
535        while let Some(cid) = queue.pop() {
536            if !visited.insert(cid) {
537                continue;
538            }
539
540            if ancestors1.contains(&cid) {
541                return Ok(Some(cid));
542            }
543
544            let block = self
545                .store
546                .get(&cid)
547                .await?
548                .ok_or_else(|| Error::NotFound(format!("Commit not found: {cid}")))?;
549
550            let commit = Commit::from_block(&block)?;
551            queue.extend(commit.parents.iter().copied());
552        }
553
554        Ok(None)
555    }
556
557    /// Check if commit1 is an ancestor of commit2 (i.e., fast-forward is possible)
558    pub async fn is_ancestor(&self, ancestor: &Cid, descendant: &Cid) -> Result<bool> {
559        if ancestor == descendant {
560            return Ok(true);
561        }
562
563        let mut queue = vec![*descendant];
564        let mut visited = std::collections::HashSet::new();
565
566        while let Some(cid) = queue.pop() {
567            if !visited.insert(cid) {
568                continue;
569            }
570
571            if &cid == ancestor {
572                return Ok(true);
573            }
574
575            let block = self
576                .store
577                .get(&cid)
578                .await?
579                .ok_or_else(|| Error::NotFound(format!("Commit not found: {cid}")))?;
580
581            let commit = Commit::from_block(&block)?;
582            queue.extend(commit.parents.iter().copied());
583        }
584
585        Ok(false)
586    }
587
588    /// Merge a branch into the current HEAD
589    ///
590    /// # Arguments
591    /// * `branch_cid` - The commit CID to merge into current HEAD
592    /// * `message` - Merge commit message
593    /// * `author` - Author of the merge commit
594    /// * `strategy` - Merge strategy to use
595    pub async fn merge(
596        &self,
597        branch_cid: &Cid,
598        message: String,
599        author: Author,
600        strategy: MergeStrategy,
601    ) -> Result<MergeResult> {
602        let head_cid = self
603            .head
604            .read()
605            .ok_or_else(|| Error::Storage("No HEAD commit".to_string()))?;
606
607        // Check if already up to date
608        if &head_cid == branch_cid {
609            return Ok(MergeResult::FastForward { target: head_cid });
610        }
611
612        // Check if fast-forward is possible
613        if self.is_ancestor(&head_cid, branch_cid).await? {
614            // Fast-forward merge
615            *self.head.write() = Some(*branch_cid);
616
617            // Update current branch ref
618            let branch_name = self.current_branch.read().clone();
619            self.refs_cache.insert(branch_name.clone(), *branch_cid);
620
621            return Ok(MergeResult::FastForward {
622                target: *branch_cid,
623            });
624        }
625
626        // Fast-forward only strategy fails if not possible
627        if strategy == MergeStrategy::FastForward {
628            return Err(Error::Storage(
629                "Fast-forward not possible, branches have diverged".to_string(),
630            ));
631        }
632
633        // Load both commits
634        let head_block = self
635            .store
636            .get(&head_cid)
637            .await?
638            .ok_or_else(|| Error::NotFound(format!("HEAD commit not found: {head_cid}")))?;
639        let head_commit = Commit::from_block(&head_block)?;
640
641        let branch_block = self
642            .store
643            .get(branch_cid)
644            .await?
645            .ok_or_else(|| Error::NotFound(format!("Branch commit not found: {branch_cid}")))?;
646        let branch_commit = Commit::from_block(&branch_block)?;
647
648        // Three-way merge: create a merge commit with both parents
649        match strategy {
650            MergeStrategy::ThreeWay | MergeStrategy::Ours | MergeStrategy::Theirs => {
651                // For now, we'll use the branch's root for the merge
652                // In a real implementation, we'd need to:
653                // 1. Find common ancestor
654                // 2. Compute diff from ancestor to head
655                // 3. Compute diff from ancestor to branch
656                // 4. Apply both diffs and resolve conflicts
657                // For simplicity, we'll use the strategy to pick a root:
658                let merge_root = match strategy {
659                    MergeStrategy::Ours => head_commit.root,
660                    MergeStrategy::Theirs => branch_commit.root,
661                    MergeStrategy::ThreeWay => {
662                        // Use branch's root (in real impl, would merge properly)
663                        branch_commit.root
664                    }
665                    _ => unreachable!(),
666                };
667
668                // Create merge commit with both parents
669                let mut merge_commit = Commit::new(
670                    vec![head_cid, *branch_cid],
671                    merge_root,
672                    message,
673                    author,
674                    HashMap::new(),
675                );
676
677                let merge_cid = merge_commit.finalize()?;
678                let merge_block = merge_commit.to_block()?;
679                self.store.put(&merge_block).await?;
680
681                // Update HEAD
682                *self.head.write() = Some(merge_cid);
683
684                // Update current branch ref
685                let branch_name = self.current_branch.read().clone();
686                self.refs_cache.insert(branch_name.clone(), merge_cid);
687
688                Ok(MergeResult::MergeCommit { commit: merge_cid })
689            }
690            MergeStrategy::FastForward => unreachable!(), // Already handled above
691        }
692    }
693
694    /// Merge a named branch into current HEAD
695    pub async fn merge_branch(
696        &self,
697        branch_name: &str,
698        message: String,
699        author: Author,
700        strategy: MergeStrategy,
701    ) -> Result<MergeResult> {
702        let branch_ref = self.get_ref(branch_name).await?;
703        self.merge(&branch_ref.commit, message, author, strategy)
704            .await
705    }
706}
707
708/// Commit builder for ergonomic commit creation
709pub struct CommitBuilder {
710    parents: Vec<Cid>,
711    root: Option<Cid>,
712    message: Option<String>,
713    author: Option<Author>,
714    metadata: HashMap<String, String>,
715}
716
717impl CommitBuilder {
718    /// Create a new commit builder
719    pub fn new() -> Self {
720        Self {
721            parents: Vec::new(),
722            root: None,
723            message: None,
724            author: None,
725            metadata: HashMap::new(),
726        }
727    }
728
729    /// Set parent commits
730    #[must_use]
731    pub fn parents(mut self, parents: Vec<Cid>) -> Self {
732        self.parents = parents;
733        self
734    }
735
736    /// Set root CID
737    #[must_use]
738    pub fn root(mut self, root: Cid) -> Self {
739        self.root = Some(root);
740        self
741    }
742
743    /// Set commit message
744    #[must_use]
745    pub fn message(mut self, message: String) -> Self {
746        self.message = Some(message);
747        self
748    }
749
750    /// Set author
751    #[must_use]
752    pub fn author(mut self, author: Author) -> Self {
753        self.author = Some(author);
754        self
755    }
756
757    /// Add metadata entry
758    #[must_use]
759    pub fn metadata(mut self, key: String, value: String) -> Self {
760        self.metadata.insert(key, value);
761        self
762    }
763
764    /// Build the commit
765    pub fn build(self) -> Result<Commit> {
766        let root = self
767            .root
768            .ok_or_else(|| Error::Storage("Root CID is required".to_string()))?;
769        let message = self
770            .message
771            .ok_or_else(|| Error::Storage("Commit message is required".to_string()))?;
772        let author = self
773            .author
774            .ok_or_else(|| Error::Storage("Author is required".to_string()))?;
775
776        Ok(Commit::new(
777            self.parents,
778            root,
779            message,
780            author,
781            self.metadata,
782        ))
783    }
784}
785
786impl Default for CommitBuilder {
787    fn default() -> Self {
788        Self::new()
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use crate::blockstore::{BlockStoreConfig, SledBlockStore};
796    use std::path::PathBuf;
797
798    #[tokio::test]
799    async fn test_commit_creation() {
800        let author = Author {
801            name: "Test User".to_string(),
802            email: "test@example.com".to_string(),
803        };
804
805        let root_block = Block::new(Bytes::from("model weights")).unwrap();
806        let root_cid = *root_block.cid();
807
808        let mut commit = Commit::new(
809            vec![],
810            root_cid,
811            "Initial commit".to_string(),
812            author,
813            HashMap::new(),
814        );
815
816        let commit_cid = commit.finalize().unwrap();
817        assert!(commit.cid.is_some());
818        assert_eq!(commit.cid(), &commit_cid);
819        assert!(commit.is_initial());
820    }
821
822    #[tokio::test]
823    async fn test_commit_serialization() {
824        let author = Author {
825            name: "Test User".to_string(),
826            email: "test@example.com".to_string(),
827        };
828
829        let root_block = Block::new(Bytes::from("model weights")).unwrap();
830        let root_cid = *root_block.cid();
831
832        let mut commit = Commit::new(
833            vec![],
834            root_cid,
835            "Initial commit".to_string(),
836            author.clone(),
837            HashMap::new(),
838        );
839
840        commit.finalize().unwrap();
841        let commit_block = commit.to_block().unwrap();
842        let deserialized = Commit::from_block(&commit_block).unwrap();
843
844        assert_eq!(commit, deserialized);
845    }
846
847    #[tokio::test]
848    async fn test_version_control_initial_commit() {
849        let config = BlockStoreConfig {
850            path: PathBuf::from("/tmp/ipfrs-vcs-test-initial"),
851            cache_size: 10 * 1024 * 1024,
852        };
853        let _ = std::fs::remove_dir_all(&config.path);
854
855        let store = Arc::new(SledBlockStore::new(config).unwrap());
856        let vcs = VersionControl::new(store.clone());
857
858        // Create root block (model)
859        let model_block = Block::new(Bytes::from("model v1")).unwrap();
860        let model_cid = *model_block.cid();
861        store.put(&model_block).await.unwrap();
862
863        // Create initial commit
864        let author = Author {
865            name: "Test User".to_string(),
866            email: "test@example.com".to_string(),
867        };
868
869        let commit_cid = vcs
870            .commit(
871                model_cid,
872                "Initial commit".to_string(),
873                author,
874                HashMap::new(),
875            )
876            .await
877            .unwrap();
878
879        // Verify HEAD is updated
880        assert_eq!(vcs.head(), Some(commit_cid));
881
882        // Verify we can load the commit
883        let commit_block = store.get(&commit_cid).await.unwrap().unwrap();
884        let commit = Commit::from_block(&commit_block).unwrap();
885        assert_eq!(commit.root, model_cid);
886        assert_eq!(commit.message, "Initial commit");
887        assert!(commit.is_initial());
888    }
889
890    #[tokio::test]
891    async fn test_version_control_multiple_commits() {
892        let config = BlockStoreConfig {
893            path: PathBuf::from("/tmp/ipfrs-vcs-test-multiple"),
894            cache_size: 10 * 1024 * 1024,
895        };
896        let _ = std::fs::remove_dir_all(&config.path);
897
898        let store = Arc::new(SledBlockStore::new(config).unwrap());
899        let vcs = VersionControl::new(store.clone());
900
901        let author = Author {
902            name: "Test User".to_string(),
903            email: "test@example.com".to_string(),
904        };
905
906        // First commit
907        let model1 = Block::new(Bytes::from("model v1")).unwrap();
908        store.put(&model1).await.unwrap();
909        let commit1 = vcs
910            .commit(
911                *model1.cid(),
912                "First commit".to_string(),
913                author.clone(),
914                HashMap::new(),
915            )
916            .await
917            .unwrap();
918
919        // Second commit
920        let model2 = Block::new(Bytes::from("model v2")).unwrap();
921        store.put(&model2).await.unwrap();
922        let commit2 = vcs
923            .commit(
924                *model2.cid(),
925                "Second commit".to_string(),
926                author,
927                HashMap::new(),
928            )
929            .await
930            .unwrap();
931
932        // Verify HEAD is at second commit
933        assert_eq!(vcs.head(), Some(commit2));
934
935        // Load second commit and verify it has first commit as parent
936        let commit2_block = store.get(&commit2).await.unwrap().unwrap();
937        let commit2_obj = Commit::from_block(&commit2_block).unwrap();
938        assert_eq!(commit2_obj.parents, vec![commit1]);
939    }
940
941    #[tokio::test]
942    async fn test_checkout() {
943        let config = BlockStoreConfig {
944            path: PathBuf::from("/tmp/ipfrs-vcs-test-checkout"),
945            cache_size: 10 * 1024 * 1024,
946        };
947        let _ = std::fs::remove_dir_all(&config.path);
948
949        let store = Arc::new(SledBlockStore::new(config).unwrap());
950        let vcs = VersionControl::new(store.clone());
951
952        let author = Author {
953            name: "Test User".to_string(),
954            email: "test@example.com".to_string(),
955        };
956
957        // Create two commits
958        let model1 = Block::new(Bytes::from("model v1")).unwrap();
959        store.put(&model1).await.unwrap();
960        let commit1 = vcs
961            .commit(
962                *model1.cid(),
963                "First".to_string(),
964                author.clone(),
965                HashMap::new(),
966            )
967            .await
968            .unwrap();
969
970        let model2 = Block::new(Bytes::from("model v2")).unwrap();
971        store.put(&model2).await.unwrap();
972        let _commit2 = vcs
973            .commit(*model2.cid(), "Second".to_string(), author, HashMap::new())
974            .await
975            .unwrap();
976
977        // Checkout to first commit
978        let root = vcs.checkout(&commit1).await.unwrap();
979        assert_eq!(root, *model1.cid());
980        assert_eq!(vcs.head(), Some(commit1));
981    }
982
983    #[tokio::test]
984    async fn test_commit_log() {
985        let config = BlockStoreConfig {
986            path: PathBuf::from("/tmp/ipfrs-vcs-test-log"),
987            cache_size: 10 * 1024 * 1024,
988        };
989        let _ = std::fs::remove_dir_all(&config.path);
990
991        let store = Arc::new(SledBlockStore::new(config).unwrap());
992        let vcs = VersionControl::new(store.clone());
993
994        let author = Author {
995            name: "Test User".to_string(),
996            email: "test@example.com".to_string(),
997        };
998
999        // Create three commits
1000        let mut commits = Vec::new();
1001        for i in 1..=3 {
1002            let model = Block::new(Bytes::from(format!("model v{}", i))).unwrap();
1003            store.put(&model).await.unwrap();
1004            let commit = vcs
1005                .commit(
1006                    *model.cid(),
1007                    format!("Commit {}", i),
1008                    author.clone(),
1009                    HashMap::new(),
1010                )
1011                .await
1012                .unwrap();
1013            commits.push(commit);
1014        }
1015
1016        // Get log from HEAD
1017        let log = vcs.log(&commits[2], 10).await.unwrap();
1018        assert_eq!(log.len(), 3);
1019        assert_eq!(log[0].message, "Commit 3");
1020        assert_eq!(log[1].message, "Commit 2");
1021        assert_eq!(log[2].message, "Commit 1");
1022    }
1023
1024    #[test]
1025    fn test_commit_builder() {
1026        let author = Author {
1027            name: "Builder".to_string(),
1028            email: "builder@example.com".to_string(),
1029        };
1030
1031        let root_block = Block::new(Bytes::from("root")).unwrap();
1032
1033        let commit = CommitBuilder::new()
1034            .root(*root_block.cid())
1035            .message("Test commit".to_string())
1036            .author(author.clone())
1037            .metadata("key1".to_string(), "value1".to_string())
1038            .build()
1039            .unwrap();
1040
1041        assert_eq!(commit.message, "Test commit");
1042        assert_eq!(commit.author, author);
1043        assert_eq!(commit.metadata.get("key1").unwrap(), "value1");
1044    }
1045}