Skip to main content

cadvm_core/
repo.rs

1//! The [`Repository`] handle: on-disk layout, refs, HEAD and object I/O.
2
3use std::path::{Path, PathBuf};
4
5use cadvm_store::{Category, ObjectId, Store};
6
7use crate::error::{CoreError, Result};
8use crate::model::{Commit, CommitBody, Manifest};
9
10/// Name of the repository metadata directory.
11pub const REPO_DIR: &str = ".cadvm";
12/// The default branch created by `init`.
13pub const DEFAULT_BRANCH: &str = "main";
14
15/// Where HEAD currently points.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Head {
18    /// Attached to a branch by name (the normal case).
19    Branch(String),
20    /// Detached, pointing directly at a commit.
21    Detached(ObjectId),
22}
23
24/// A handle to an on-disk cadvm repository.
25#[derive(Debug, Clone)]
26pub struct Repository {
27    workdir: PathBuf,
28    cadvm_dir: PathBuf,
29    store: Store,
30}
31
32impl Repository {
33    /// Initialize a brand-new repository rooted at `workdir`.
34    pub fn init(workdir: impl AsRef<Path>) -> Result<Repository> {
35        let workdir = workdir.as_ref().to_path_buf();
36        let cadvm_dir = workdir.join(REPO_DIR);
37        if cadvm_dir.exists() {
38            return Err(CoreError::AlreadyInitialized(cadvm_dir));
39        }
40
41        // objects/<category> dirs are created by Store::open below.
42        let store = Store::open(cadvm_dir.join("objects"))?;
43        for sub in ["refs/heads", "tmp"] {
44            let dir = cadvm_dir.join(sub);
45            std::fs::create_dir_all(&dir).map_err(|e| CoreError::io(&dir, e))?;
46        }
47
48        let repo = Repository {
49            workdir,
50            cadvm_dir,
51            store,
52        };
53
54        // Empty default branch ref (no commits yet).
55        std::fs::write(repo.ref_path(DEFAULT_BRANCH), b"")
56            .map_err(|e| CoreError::io(repo.ref_path(DEFAULT_BRANCH), e))?;
57        repo.write_head(&Head::Branch(DEFAULT_BRANCH.to_string()))?;
58        repo.write_index_placeholder()?;
59
60        // Empty config so the file is discoverable; `config` populates it.
61        std::fs::write(repo.config_path(), b"{}\n")
62            .map_err(|e| CoreError::io(repo.config_path(), e))?;
63
64        Ok(repo)
65    }
66
67    /// Open the repository containing `start`, searching upward for `.cadvm`.
68    pub fn discover(start: impl AsRef<Path>) -> Result<Repository> {
69        let start = start.as_ref();
70        let start_abs = if start.is_absolute() {
71            start.to_path_buf()
72        } else {
73            std::env::current_dir()
74                .map_err(|e| CoreError::io(start, e))?
75                .join(start)
76        };
77        let mut dir = start_abs.as_path();
78        loop {
79            if dir.join(REPO_DIR).is_dir() {
80                return Repository::open(dir);
81            }
82            match dir.parent() {
83                Some(parent) => dir = parent,
84                None => return Err(CoreError::NotARepository(start_abs.clone())),
85            }
86        }
87    }
88
89    /// Open a repository whose root (the directory containing `.cadvm`) is known.
90    pub fn open(workdir: impl AsRef<Path>) -> Result<Repository> {
91        let workdir = workdir.as_ref().to_path_buf();
92        let cadvm_dir = workdir.join(REPO_DIR);
93        if !cadvm_dir.is_dir() {
94            return Err(CoreError::NotARepository(workdir));
95        }
96        let store = Store::open(cadvm_dir.join("objects"))?;
97        Ok(Repository {
98            workdir,
99            cadvm_dir,
100            store,
101        })
102    }
103
104    // --- paths --------------------------------------------------------------
105
106    /// Repository root (the directory that contains `.cadvm`).
107    pub fn workdir(&self) -> &Path {
108        &self.workdir
109    }
110
111    /// The `.cadvm` directory.
112    pub fn cadvm_dir(&self) -> &Path {
113        &self.cadvm_dir
114    }
115
116    /// The shared object store.
117    pub fn store(&self) -> &Store {
118        &self.store
119    }
120
121    fn head_path(&self) -> PathBuf {
122        self.cadvm_dir.join("HEAD")
123    }
124
125    fn refs_heads_dir(&self) -> PathBuf {
126        self.cadvm_dir.join("refs/heads")
127    }
128
129    fn ref_path(&self, branch: &str) -> PathBuf {
130        self.refs_heads_dir().join(branch)
131    }
132
133    fn index_path(&self) -> PathBuf {
134        self.cadvm_dir.join("index.json")
135    }
136
137    /// Path to the repository config file.
138    pub fn config_path(&self) -> PathBuf {
139        self.cadvm_dir.join("config.json")
140    }
141
142    /// The `tmp/` scratch directory.
143    pub fn tmp_dir(&self) -> PathBuf {
144        self.cadvm_dir.join("tmp")
145    }
146
147    fn write_index_placeholder(&self) -> Result<()> {
148        // index.json is reserved for a future staging area; V1 snapshots the
149        // whole working tree, so we just keep a stable, valid JSON placeholder.
150        let path = self.index_path();
151        std::fs::write(&path, b"{\n  \"version\": 1,\n  \"entries\": []\n}\n")
152            .map_err(|e| CoreError::io(&path, e))
153    }
154
155    // --- HEAD & refs --------------------------------------------------------
156
157    /// Read the current HEAD.
158    pub fn read_head(&self) -> Result<Head> {
159        let path = self.head_path();
160        let raw = std::fs::read_to_string(&path).map_err(|e| CoreError::io(&path, e))?;
161        let raw = raw.trim();
162        if let Some(rest) = raw.strip_prefix("ref:") {
163            let refname = rest.trim();
164            let branch = refname
165                .strip_prefix("refs/heads/")
166                .unwrap_or(refname)
167                .to_string();
168            Ok(Head::Branch(branch))
169        } else {
170            let id = raw
171                .parse::<ObjectId>()
172                .map_err(|_| CoreError::UnknownRevision(raw.to_string()))?;
173            Ok(Head::Detached(id))
174        }
175    }
176
177    /// Overwrite HEAD.
178    pub fn write_head(&self, head: &Head) -> Result<()> {
179        let path = self.head_path();
180        let contents = match head {
181            Head::Branch(name) => format!("ref: refs/heads/{name}\n"),
182            Head::Detached(id) => format!("{}\n", id.canonical()),
183        };
184        std::fs::write(&path, contents).map_err(|e| CoreError::io(&path, e))
185    }
186
187    /// The branch name HEAD is attached to, if any.
188    pub fn current_branch(&self) -> Result<Option<String>> {
189        match self.read_head()? {
190            Head::Branch(name) => Ok(Some(name)),
191            Head::Detached(_) => Ok(None),
192        }
193    }
194
195    /// Read a branch ref. Returns `None` if the branch has no commit yet.
196    pub fn read_ref(&self, branch: &str) -> Result<Option<ObjectId>> {
197        let path = self.ref_path(branch);
198        match std::fs::read_to_string(&path) {
199            Ok(s) => {
200                let s = s.trim();
201                if s.is_empty() {
202                    Ok(None)
203                } else {
204                    Ok(Some(s.parse::<ObjectId>().map_err(|_| {
205                        CoreError::UnknownRevision(format!("refs/heads/{branch}"))
206                    })?))
207                }
208            }
209            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
210                Err(CoreError::NoSuchBranch(branch.to_string()))
211            }
212            Err(e) => Err(CoreError::io(&path, e)),
213        }
214    }
215
216    /// Point a branch ref at a commit (creating the ref file if needed).
217    pub fn write_ref(&self, branch: &str, commit: &ObjectId) -> Result<()> {
218        let path = self.ref_path(branch);
219        std::fs::write(&path, format!("{}\n", commit.canonical()))
220            .map_err(|e| CoreError::io(&path, e))
221    }
222
223    /// Whether a branch exists.
224    pub fn branch_exists(&self, branch: &str) -> bool {
225        self.ref_path(branch).exists()
226    }
227
228    /// List all branch names, sorted.
229    pub fn list_branches(&self) -> Result<Vec<String>> {
230        let dir = self.refs_heads_dir();
231        let mut names = Vec::new();
232        let entries = std::fs::read_dir(&dir).map_err(|e| CoreError::io(&dir, e))?;
233        for entry in entries {
234            let entry = entry.map_err(|e| CoreError::io(&dir, e))?;
235            if entry
236                .file_type()
237                .map_err(|e| CoreError::io(entry.path(), e))?
238                .is_file()
239            {
240                if let Some(name) = entry.file_name().to_str() {
241                    names.push(name.to_string());
242                }
243            }
244        }
245        names.sort();
246        Ok(names)
247    }
248
249    /// Create a new branch pointing at `commit`. Errors if it already exists or
250    /// the name is invalid.
251    pub fn create_branch(&self, name: &str, commit: &ObjectId) -> Result<()> {
252        validate_branch_name(name)?;
253        if self.branch_exists(name) {
254            return Err(CoreError::BranchExists(name.to_string()));
255        }
256        self.write_ref(name, commit)
257    }
258
259    /// Delete a branch ref. Refuses to delete the branch HEAD is on.
260    pub fn delete_branch(&self, name: &str) -> Result<()> {
261        if !self.branch_exists(name) {
262            return Err(CoreError::NoSuchBranch(name.to_string()));
263        }
264        if self.current_branch()?.as_deref() == Some(name) {
265            return Err(CoreError::CannotDeleteCurrentBranch(name.to_string()));
266        }
267        let path = self.ref_path(name);
268        std::fs::remove_file(&path).map_err(|e| CoreError::io(&path, e))
269    }
270
271    // --- HEAD commit resolution --------------------------------------------
272
273    /// The commit id HEAD currently resolves to, if any.
274    pub fn head_commit_id(&self) -> Result<Option<ObjectId>> {
275        match self.read_head()? {
276            Head::Branch(name) => match self.read_ref(&name) {
277                Ok(opt) => Ok(opt),
278                // A fresh repo's default branch ref exists but is empty; treat a
279                // missing ref the same as "no commit yet".
280                Err(CoreError::NoSuchBranch(_)) => Ok(None),
281                Err(e) => Err(e),
282            },
283            Head::Detached(id) => Ok(Some(id)),
284        }
285    }
286
287    // --- object I/O ---------------------------------------------------------
288
289    /// Persist a commit body and return its content id.
290    pub fn write_commit(&self, body: &CommitBody) -> Result<ObjectId> {
291        Ok(self.store.put_json(Category::Commit, body)?)
292    }
293
294    /// Read a commit by id.
295    pub fn read_commit(&self, id: &ObjectId) -> Result<Commit> {
296        let body: CommitBody = self.store.get_json(Category::Commit, id)?;
297        Ok(Commit::from_body(id.clone(), body))
298    }
299
300    /// Persist a manifest and return its content id.
301    pub fn write_manifest(&self, manifest: &Manifest) -> Result<ObjectId> {
302        Ok(self.store.put_json(Category::Manifest, manifest)?)
303    }
304
305    /// Read a manifest by id.
306    pub fn read_manifest(&self, id: &ObjectId) -> Result<Manifest> {
307        Ok(self.store.get_json(Category::Manifest, id)?)
308    }
309
310    /// Read the manifest belonging to a commit.
311    pub fn manifest_of_commit(&self, commit: &ObjectId) -> Result<Manifest> {
312        let commit = self.read_commit(commit)?;
313        self.read_manifest(&commit.manifest)
314    }
315
316    /// The manifest HEAD points to, or an empty manifest if there are no commits.
317    pub fn head_manifest(&self) -> Result<Manifest> {
318        match self.head_commit_id()? {
319            Some(id) => self.manifest_of_commit(&id),
320            None => Ok(Manifest::empty()),
321        }
322    }
323}
324
325/// Validate a branch name: non-empty, no path separators, whitespace, or
326/// leading dash, and not a dotted special name.
327pub fn validate_branch_name(name: &str) -> Result<()> {
328    let invalid = name.is_empty()
329        || name.starts_with('-')
330        || name == "."
331        || name == ".."
332        || name.contains("..")
333        || name.contains(|c: char| c.is_whitespace() || c == '/' || c == '\\' || c == ':');
334    if invalid {
335        Err(CoreError::InvalidBranchName(name.to_string()))
336    } else {
337        Ok(())
338    }
339}