Skip to main content

void_core/sdk/
repo.rs

1//! Repo — the primary SDK entry point.
2
3use std::io::Read;
4use std::path::Path;
5use std::sync::Arc;
6
7use crate::cid::ToVoidCid;
8use crate::metadata::ShardMap;
9use crate::pipeline::{commit_workspace, CommitOptions, SealOptions};
10use crate::pipeline::{
11    push_repo, pull_repo, PushOptions, PullOptions, PushResult, PullResult, CloneMode,
12};
13use crate::refs;
14use crate::store::{FsStore, IpfsBackend, RemoteStore};
15use crate::workspace::checkout::{checkout_tree, CheckoutOptions, CheckoutStats};
16use crate::workspace::reset::{reset_paths, ResetOptions, ResetResult};
17use crate::workspace::stage::{stage_paths, StageOptions, StageResult};
18use crate::workspace::status::{status_workspace, StatusOptions, StatusResult};
19use crate::{Result, VoidContext, VoidError};
20
21use super::builder::RepoBuilder;
22use super::types::{CommitId, CommitInfo, DirEntry};
23
24/// A void repository — the primary SDK entry point.
25///
26/// Thread-safe and cheap to clone (internal state is Arc-wrapped).
27#[derive(Clone)]
28pub struct Repo {
29    ctx: VoidContext,
30    remote: Option<Arc<dyn RemoteStore>>,
31}
32
33impl Repo {
34    /// Start building a Repo.
35    pub fn builder(path: impl AsRef<Path>) -> RepoBuilder {
36        RepoBuilder::new(path)
37    }
38
39    /// Create from an existing VoidContext (internal).
40    pub(crate) fn from_context(ctx: VoidContext) -> Self {
41        Self { ctx, remote: None }
42    }
43
44    /// Create from a VoidContext with an optional remote store.
45    pub(crate) fn from_context_with_remote(
46        ctx: VoidContext,
47        remote: Option<Arc<dyn RemoteStore>>,
48    ) -> Self {
49        Self { ctx, remote }
50    }
51
52    // --- Read operations ---
53
54    /// Get information about the current HEAD commit.
55    pub fn head(&self) -> Result<CommitInfo> {
56        let commit_cid = refs::resolve_head(&self.ctx.paths.void_dir)?
57            .ok_or_else(|| VoidError::NotFound("no HEAD commit".into()))?;
58
59        let store = self.open_store()?;
60        let void_cid = commit_cid.to_void_cid()?;
61        let (commit, _reader) = self.ctx.load_commit(&store, &void_cid)?;
62
63        Ok(CommitInfo {
64            id: CommitId(commit_cid.as_bytes().to_vec()),
65            message: commit.message.clone(),
66            timestamp: commit.timestamp,
67            author: commit.author.as_ref().map(|a| a.to_hex()),
68            parents: commit
69                .parents
70                .iter()
71                .map(|p| CommitId(p.as_bytes().to_vec()))
72                .collect(),
73        })
74    }
75
76    /// Stream a file from HEAD. Returns a reader that fetches shards on demand.
77    pub fn stream_file(&self, path: &str) -> Result<impl Read + '_> {
78        self.read_file(path).map(std::io::Cursor::new)
79    }
80
81    /// Stream a file from a specific commit.
82    pub fn stream_file_at(&self, commit: &CommitId, path: &str) -> Result<impl Read + '_> {
83        let commit_cid = crate::crypto::CommitCid::from_bytes(commit.0.clone());
84        let store = self.open_store()?;
85        let void_cid = commit_cid.to_void_cid()?;
86        let (commit_obj, reader) = self.ctx.load_commit(&store, &void_cid)?;
87        let ancestor_keys =
88            crate::crypto::collect_ancestor_content_keys_vault(&self.ctx.crypto.vault, &store, &commit_obj);
89        let content = self.ctx.read_file_from_commit(&store, &commit_obj, &reader, &ancestor_keys, path)?;
90        Ok(std::io::Cursor::new(content))
91    }
92
93    /// Read a file fully into memory from HEAD.
94    pub fn read_file(&self, path: &str) -> Result<Vec<u8>> {
95        let commit_cid = refs::resolve_head(&self.ctx.paths.void_dir)?
96            .ok_or_else(|| VoidError::NotFound("no HEAD commit".into()))?;
97
98        let store = self.open_store()?;
99        let void_cid = commit_cid.to_void_cid()?;
100        let (commit, reader) = self.ctx.load_commit(&store, &void_cid)?;
101        let ancestor_keys =
102            crate::crypto::collect_ancestor_content_keys_vault(&self.ctx.crypto.vault, &store, &commit);
103
104        self.ctx
105            .read_file_from_commit(&store, &commit, &reader, &ancestor_keys, path)
106    }
107
108    /// List directory contents from a specific ref (branch, "HEAD", or CID).
109    pub fn list_dir_at(&self, refstr: &str, path: &str) -> Result<Vec<DirEntry>> {
110        let commit_cid = self.resolve_ref(refstr)?;
111        let store = self.open_store()?;
112        let void_cid = commit_cid.to_void_cid()?;
113        let (commit, reader) = self.ctx.load_commit(&store, &void_cid)?;
114        let manifest = self.ctx.load_manifest(&store, &commit, &reader)?
115            .ok_or_else(|| VoidError::NotFound("no manifest in commit".into()))?;
116
117        let mut entries = Vec::new();
118        for entry_result in manifest.iter() {
119            let entry = entry_result?;
120            if entry.path.starts_with(path) || path == "." || path.is_empty() {
121                entries.push(DirEntry {
122                    path: entry.path.clone(),
123                    size: entry.size,
124                    is_large: entry.shard_count > 1,
125                });
126            }
127        }
128        Ok(entries)
129    }
130
131    /// List directory contents from HEAD.
132    pub fn list_dir(&self, path: &str) -> Result<Vec<DirEntry>> {
133        let commit_cid = refs::resolve_head(&self.ctx.paths.void_dir)?
134            .ok_or_else(|| VoidError::NotFound("no HEAD commit".into()))?;
135
136        let store = self.open_store()?;
137        let void_cid = commit_cid.to_void_cid()?;
138        let (commit, reader) = self.ctx.load_commit(&store, &void_cid)?;
139
140        let manifest = self
141            .ctx
142            .load_manifest(&store, &commit, &reader)?
143            .ok_or_else(|| VoidError::NotFound("no manifest in commit".into()))?;
144
145        let mut entries = Vec::new();
146        for entry_result in manifest.iter() {
147            let entry = entry_result?;
148            if entry.path.starts_with(path) || path == "." || path.is_empty() {
149                entries.push(DirEntry {
150                    path: entry.path.clone(),
151                    size: entry.size,
152                    is_large: entry.shard_count > 1,
153                });
154            }
155        }
156
157        Ok(entries)
158    }
159
160    /// Compute repository status.
161    pub fn status(&self) -> Result<StatusResult> {
162        self.status_with_options(vec![], None)
163    }
164
165    /// Compute repository status with path patterns and optional observer.
166    pub fn status_with_options(
167        &self,
168        patterns: Vec<String>,
169        observer: Option<std::sync::Arc<dyn crate::support::events::VoidObserver>>,
170    ) -> Result<StatusResult> {
171        status_workspace(StatusOptions {
172            ctx: self.ctx.clone(),
173            patterns,
174            observer,
175        })
176    }
177
178    // --- Write operations ---
179
180    /// Stage files for commit.
181    pub fn add(&self, paths: &[&str]) -> Result<StageResult> {
182        self.add_with_options(paths, None)
183    }
184
185    /// Stage files with optional progress observer.
186    pub fn add_with_options(
187        &self,
188        paths: &[&str],
189        observer: Option<std::sync::Arc<dyn crate::support::events::VoidObserver>>,
190    ) -> Result<StageResult> {
191        stage_paths(StageOptions {
192            ctx: self.ctx.clone(),
193            patterns: paths.iter().map(|p| p.to_string()).collect(),
194            observer,
195        })
196    }
197
198    /// Create a commit from staged changes (or full workspace if no index).
199    pub fn commit(&self, message: &str) -> Result<CommitInfo> {
200        let parent_cid = refs::resolve_head(&self.ctx.paths.void_dir)?;
201
202        let seal_opts = SealOptions {
203            ctx: self.ctx.clone(),
204            shard_map: ShardMap::new(64),
205            content_key: None,
206            parent_content_key: None,
207        };
208
209        let commit_opts = CommitOptions {
210            seal: seal_opts,
211            message: message.to_string(),
212            parent_cid,
213            allow_data_loss: false,
214            foreign_parent: false,
215        };
216
217        let result = commit_workspace(commit_opts)?;
218
219        Ok(CommitInfo {
220            id: CommitId(result.commit_cid.as_bytes().to_vec()),
221            message: message.to_string(),
222            timestamp: 0, // commit_workspace doesn't return timestamp directly
223            author: None,
224            parents: vec![],
225        })
226    }
227
228    /// Restore files from HEAD to working tree.
229    pub fn restore(&self, paths: &[&str]) -> Result<CheckoutStats> {
230        self.restore_from_ref("HEAD", paths, false, None)
231    }
232
233    /// Restore files from a specific ref (branch name, "HEAD", or commit CID).
234    pub fn restore_from(
235        &self,
236        source: &str,
237        paths: &[&str],
238    ) -> Result<CheckoutStats> {
239        self.restore_from_ref(source, paths, false, None)
240    }
241
242    /// Restore with full options: source ref, force flag, observer.
243    pub fn restore_from_ref(
244        &self,
245        source: &str,
246        paths: &[&str],
247        force: bool,
248        observer: Option<std::sync::Arc<dyn crate::support::events::VoidObserver>>,
249    ) -> Result<CheckoutStats> {
250        let commit_cid = self.resolve_ref(source)?;
251        let void_cid = commit_cid.to_void_cid()?;
252        let store = self.open_store()?;
253
254        let has_paths = !paths.is_empty();
255        let options = CheckoutOptions {
256            paths: if has_paths { Some(paths.iter().map(|p| p.to_string()).collect()) } else { None },
257            force: force || has_paths, // path-specific restore always forces
258            observer,
259            workspace_dir: None,
260            include_large: has_paths, // path-specific includes large files
261        };
262
263        checkout_tree(&store, &self.ctx.crypto.vault, &void_cid, self.ctx.paths.root.as_std_path(), &options)
264    }
265
266    /// Unstage files (reset index entries to match HEAD).
267    pub fn reset(&self, paths: &[&str]) -> Result<ResetResult> {
268        self.reset_with_options(paths, None)
269    }
270
271    /// Unstage files with optional progress observer.
272    pub fn reset_with_options(
273        &self,
274        paths: &[&str],
275        observer: Option<std::sync::Arc<dyn crate::support::events::VoidObserver>>,
276    ) -> Result<ResetResult> {
277        reset_paths(ResetOptions {
278            ctx: self.ctx.clone(),
279            patterns: paths.iter().map(|p| p.to_string()).collect(),
280            observer,
281        })
282    }
283
284    /// Remove files from the index (and optionally working tree).
285    pub fn remove(
286        &self,
287        paths: &[&str],
288        cached_only: bool,
289        force: bool,
290        recursive: bool,
291    ) -> Result<crate::workspace::remove::RemoveResult> {
292        crate::workspace::remove::remove_paths(crate::workspace::remove::RemoveOptions {
293            ctx: self.ctx.clone(),
294            paths: paths.iter().map(|p| p.to_string()).collect(),
295            cached_only,
296            force,
297            recursive,
298        })
299    }
300
301    /// Move/rename a file in the index and working tree.
302    pub fn mv(&self, source: &str, dest: &str) -> Result<crate::workspace::move_path::MoveResult> {
303        crate::workspace::move_path::move_path(crate::workspace::move_path::MoveOptions {
304            ctx: self.ctx.clone(),
305            source: source.to_string(),
306            dest: dest.to_string(),
307        })
308    }
309
310    // --- History ---
311
312    /// Walk commit history from HEAD, most recent first.
313    ///
314    /// Stops gracefully at foreign-repo boundaries (parent commits encrypted
315    /// with a different key, e.g., fork sources).
316    pub fn log(&self, limit: usize) -> Result<Vec<CommitInfo>> {
317        let store = self.open_store()?;
318        let walker = crate::ops::traversal::walk_from_head(
319            &store,
320            &self.ctx.crypto.vault,
321            &self.ctx.paths.void_dir,
322            Some(limit),
323        )?;
324
325        let mut commits = Vec::new();
326        for walked in walker {
327            match walked {
328                Ok(w) => {
329                    commits.push(CommitInfo {
330                        id: CommitId(w.cid.to_bytes()),
331                        message: w.commit.message.clone(),
332                        timestamp: w.commit.timestamp,
333                        author: w.commit.author.as_ref().map(|a| a.to_hex()),
334                        parents: w.commit.parents.iter()
335                            .map(|p| CommitId(p.as_bytes().to_vec()))
336                            .collect(),
337                    });
338                }
339                Err(_) if !commits.is_empty() => {
340                    // Foreign-repo boundary — parent commit can't be decrypted.
341                    // Stop walking gracefully instead of propagating the error.
342                    break;
343                }
344                Err(e) => return Err(e), // First commit failed — real error
345            }
346        }
347        Ok(commits)
348    }
349
350    /// Diff working tree against HEAD.
351    pub fn diff(&self) -> Result<crate::diff::TreeDiff> {
352        let commit_cid = refs::resolve_head(&self.ctx.paths.void_dir)?
353            .ok_or_else(|| VoidError::NotFound("no HEAD commit".into()))?;
354
355        let store = self.open_store()?;
356        let void_cid = commit_cid.to_void_cid()?;
357
358        crate::diff::diff_working(
359            &store,
360            &self.ctx.crypto.vault,
361            &void_cid,
362            self.ctx.paths.root.as_std_path(),
363        )
364    }
365
366    /// Diff staged changes against HEAD.
367    pub fn diff_staged(&self) -> Result<crate::diff::TreeDiff> {
368        let index = crate::index::read_index(
369            self.ctx.paths.void_dir.as_std_path(),
370            self.ctx.crypto.vault.index_key()?,
371        )?;
372        crate::diff::diff_staged(
373            &self.open_store()?,
374            &self.ctx.crypto.vault,
375            self.ctx.paths.void_dir.as_std_path(),
376            &index,
377        )
378    }
379
380    /// Diff between two commits.
381    pub fn diff_commits(&self, from: &CommitId, to: &CommitId) -> Result<crate::diff::TreeDiff> {
382        let store = self.open_store()?;
383        let from_cid = crate::cid::from_bytes(&from.0)?;
384        let to_cid = crate::cid::from_bytes(&to.0)?;
385
386        crate::diff::diff_commits(
387            &store,
388            &self.ctx.crypto.vault,
389            Some(&from_cid),
390            &to_cid,
391        )
392    }
393
394    // --- Branches ---
395
396    /// List all branches.
397    pub fn branches(&self) -> Result<Vec<String>> {
398        refs::list_branches(&self.ctx.paths.void_dir)
399    }
400
401    /// Create a branch pointing at HEAD.
402    pub fn branch(&self, name: &str) -> Result<()> {
403        let commit_cid = refs::resolve_head(&self.ctx.paths.void_dir)?
404            .ok_or_else(|| VoidError::NotFound("no HEAD commit".into()))?;
405        refs::write_branch(&self.ctx.paths.void_dir, name, &commit_cid)
406    }
407
408    /// Create a branch pointing at a specific commit.
409    pub fn branch_at(&self, name: &str, commit: &CommitId) -> Result<()> {
410        let commit_cid = crate::crypto::CommitCid::from_bytes(commit.0.clone());
411        refs::write_branch(&self.ctx.paths.void_dir, name, &commit_cid)
412    }
413
414    /// Delete a branch.
415    pub fn delete_branch(&self, name: &str) -> Result<()> {
416        refs::delete_branch(&self.ctx.paths.void_dir, name)
417    }
418
419    /// Switch to a branch (checkout its commit).
420    pub fn switch(&self, name: &str) -> Result<CheckoutStats> {
421        let commit_cid = refs::read_branch(&self.ctx.paths.void_dir, name)?
422            .ok_or_else(|| VoidError::NotFound(format!("branch '{}' not found", name)))?;
423
424        let void_cid = commit_cid.to_void_cid()?;
425        let store = self.open_store()?;
426
427        let options = CheckoutOptions {
428            paths: None,
429            force: false,
430            observer: None,
431            workspace_dir: None,
432            include_large: false,
433        };
434
435        let stats = checkout_tree(&store, &self.ctx.crypto.vault, &void_cid, self.ctx.paths.root.as_std_path(), &options)?;
436
437        // Update HEAD to point at the branch
438        refs::write_head(&self.ctx.paths.void_dir, &crate::refs::HeadRef::Symbolic(name.to_string()))?;
439
440        Ok(stats)
441    }
442
443    // --- Network operations ---
444
445    /// Push commits to a remote store.
446    ///
447    /// Uses the injected remote (daemon) if set via `RepoBuilder::remote()`,
448    /// otherwise falls back to IpfsStore from config.
449    pub fn push(&self) -> Result<PushResult> {
450        let backend = self.default_backend();
451        push_repo(PushOptions {
452            ctx: self.ctx.clone(),
453            commit_cid: None,
454            backend,
455            timeout: std::time::Duration::from_secs(30),
456            pin: true,
457            backend_name: "local".to_string(),
458            full: false,
459            force: false,
460            observer: None,
461            remote: self.remote.clone(),
462        })
463    }
464
465    /// Push with options: force, full, observer, etc.
466    pub fn push_with_options(
467        &self,
468        full: bool,
469        force: bool,
470        observer: Option<Arc<dyn crate::support::events::VoidObserver>>,
471    ) -> Result<PushResult> {
472        let backend = self.default_backend();
473        push_repo(PushOptions {
474            ctx: self.ctx.clone(),
475            commit_cid: None,
476            backend,
477            timeout: std::time::Duration::from_secs(30),
478            pin: true,
479            backend_name: "local".to_string(),
480            full,
481            force,
482            observer,
483            remote: self.remote.clone(),
484        })
485    }
486
487    /// Pull a specific commit from a remote store.
488    ///
489    /// Uses the injected remote (daemon) if set, otherwise IpfsStore.
490    pub fn pull(&self, commit_cid: &str) -> Result<PullResult> {
491        let backend = self.default_backend();
492        pull_repo(PullOptions {
493            ctx: self.ctx.clone(),
494            commit_cid: commit_cid.to_string(),
495            backend,
496            timeout: std::time::Duration::from_secs(30),
497            mode: CloneMode::Full,
498            observer: None,
499            remote: self.remote.clone(),
500        })
501    }
502
503    /// Pull with options: mode, observer.
504    pub fn pull_with_options(
505        &self,
506        commit_cid: &str,
507        mode: CloneMode,
508        observer: Option<Arc<dyn crate::support::events::VoidObserver>>,
509    ) -> Result<PullResult> {
510        let backend = self.default_backend();
511        pull_repo(PullOptions {
512            ctx: self.ctx.clone(),
513            commit_cid: commit_cid.to_string(),
514            backend,
515            timeout: std::time::Duration::from_secs(30),
516            mode,
517            observer,
518            remote: self.remote.clone(),
519        })
520    }
521
522    /// Check if a remote store is configured.
523    pub fn has_remote(&self) -> bool {
524        self.remote.is_some()
525    }
526
527    // --- UnixFS operations ---
528
529    /// Add a directory as a UnixFS DAG to the local object store.
530    ///
531    /// Returns the root CID that IPFS gateways can serve as a website.
532    /// Equivalent to `ipfs add -r <path>`.
533    pub fn add_unixfs(&self, path: &std::path::Path) -> Result<crate::unixfs::AddResult> {
534        let store = self.open_store()?;
535        let adapter = crate::unixfs::FsStoreAdapter(&store);
536        crate::unixfs::add_directory(&adapter, path)
537    }
538
539    /// Add raw bytes as a UnixFS file to the local object store.
540    ///
541    /// Returns detailed info about the added file.
542    pub fn add_unixfs_bytes(&self, data: &[u8]) -> Result<crate::unixfs::FileAddResult> {
543        let store = self.open_store()?;
544        let adapter = crate::unixfs::FsStoreAdapter(&store);
545        crate::unixfs::add_bytes(&adapter, data)
546    }
547
548    /// Read a UnixFS file from the local object store.
549    ///
550    /// Equivalent to `ipfs cat <cid>`.
551    pub fn cat_unixfs(&self, cid: &cid::Cid) -> Result<Vec<u8>> {
552        let store = self.open_store()?;
553        let adapter = crate::unixfs::FsStoreAdapter(&store);
554        crate::unixfs::cat(&adapter, cid)
555    }
556
557    fn default_backend(&self) -> IpfsBackend {
558        if let Some(ref ipfs) = self.ctx.network.ipfs {
559            if let Some(ref api) = ipfs.kubo_api {
560                return IpfsBackend::Kubo { api: api.clone() };
561            }
562            if let Some(ref gw) = ipfs.gateway {
563                return IpfsBackend::Gateway { base: gw.clone() };
564            }
565        }
566        IpfsBackend::Kubo {
567            api: "http://127.0.0.1:5001".to_string(),
568        }
569    }
570
571    // --- Accessors for CLI migration ---
572    // These expose internal details needed by CLI commands that haven't
573    // been fully migrated to SDK methods. Will be removed as the SDK
574    // surface grows to cover all use cases.
575
576    /// Get the vault (for commands that need direct crypto access).
577    pub fn vault(&self) -> &std::sync::Arc<crate::crypto::KeyVault> {
578        &self.ctx.crypto.vault
579    }
580
581    /// Get the void directory path.
582    pub fn void_dir(&self) -> &camino::Utf8Path {
583        &self.ctx.paths.void_dir
584    }
585
586    /// Get the workspace root path.
587    pub fn root(&self) -> &camino::Utf8Path {
588        &self.ctx.paths.root
589    }
590
591    /// Open the object store.
592    pub fn store(&self) -> Result<FsStore> {
593        self.ctx.open_store()
594    }
595
596    /// Get the underlying VoidContext.
597    ///
598    /// This is a migration accessor for CLI commands that need direct access
599    /// to the context while the SDK surface grows. Will be removed as all
600    /// operations are covered by dedicated SDK methods.
601    pub fn context(&self) -> &VoidContext {
602        &self.ctx
603    }
604
605    // --- Helpers ---
606
607    /// Resolve a ref string to a CommitCid.
608    /// Accepts "HEAD", branch names, or raw CID strings.
609    fn resolve_ref(&self, refstr: &str) -> Result<crate::crypto::CommitCid> {
610        if refstr == "HEAD" {
611            refs::resolve_head(&self.ctx.paths.void_dir)?
612                .ok_or_else(|| VoidError::NotFound("no HEAD commit".into()))
613        } else if let Some(branch_cid) = refs::read_branch(&self.ctx.paths.void_dir, refstr)? {
614            Ok(branch_cid)
615        } else {
616            // Try as raw CID
617            let cid = crate::cid::parse(refstr)
618                .map_err(|_| VoidError::NotFound(format!("ref '{}' not found", refstr)))?;
619            Ok(crate::crypto::CommitCid::from_bytes(cid.to_bytes()))
620        }
621    }
622
623    fn open_store(&self) -> Result<FsStore> {
624        self.ctx.open_store()
625    }
626}