Skip to main content

zub/
repo.rs

1use std::fs::File;
2use std::path::{Path, PathBuf};
3
4use nix::fcntl::{Flock, FlockArg};
5
6use crate::config::Config;
7use crate::error::{Error, IoResultExt, Result};
8use crate::namespace::{current_gid_map, current_uid_map, NsConfig};
9
10/// a zub repository
11pub struct Repo {
12    path: PathBuf,
13    config: Config,
14}
15
16impl Repo {
17    /// initialize a new repository at the given path
18    pub fn init(path: &Path) -> Result<Self> {
19        let config_path = path.join("config.toml");
20        if config_path.exists() {
21            return Err(Error::RepoExists(path.to_path_buf()));
22        }
23
24        // create directory structure
25        std::fs::create_dir_all(path.join("objects/blobs")).with_path(path)?;
26        std::fs::create_dir_all(path.join("objects/trees")).with_path(path)?;
27        std::fs::create_dir_all(path.join("objects/commits")).with_path(path)?;
28        std::fs::create_dir_all(path.join("refs/heads")).with_path(path)?;
29        std::fs::create_dir_all(path.join("refs/tags")).with_path(path)?;
30        std::fs::create_dir_all(path.join("tmp")).with_path(path)?;
31
32        // capture current namespace mapping
33        let uid_map = current_uid_map()?;
34        let gid_map = current_gid_map()?;
35
36        let config = Config::new(NsConfig { uid_map, gid_map });
37        config.save(&config_path)?;
38
39        Ok(Self {
40            path: path.to_path_buf(),
41            config,
42        })
43    }
44
45    /// open an existing repository
46    pub fn open(path: &Path) -> Result<Self> {
47        let config_path = path.join("config.toml");
48        if !config_path.exists() {
49            return Err(Error::NoRepo(path.to_path_buf()));
50        }
51
52        let config = Config::load(&config_path)?;
53
54        Ok(Self {
55            path: path.to_path_buf(),
56            config,
57        })
58    }
59
60    /// repository root path
61    pub fn path(&self) -> &Path {
62        &self.path
63    }
64
65    /// repository configuration
66    pub fn config(&self) -> &Config {
67        &self.config
68    }
69
70    /// mutable access to configuration
71    pub fn config_mut(&mut self) -> &mut Config {
72        &mut self.config
73    }
74
75    /// save configuration changes
76    pub fn save_config(&self) -> Result<()> {
77        self.config.save(&self.config_path())
78    }
79
80    /// path to config.toml
81    pub fn config_path(&self) -> PathBuf {
82        self.path.join("config.toml")
83    }
84
85    /// path to objects directory
86    pub fn objects_path(&self) -> PathBuf {
87        self.path.join("objects")
88    }
89
90    /// path to blobs directory
91    pub fn blobs_path(&self) -> PathBuf {
92        self.objects_path().join("blobs")
93    }
94
95    /// path to trees directory
96    pub fn trees_path(&self) -> PathBuf {
97        self.objects_path().join("trees")
98    }
99
100    /// path to commits directory
101    pub fn commits_path(&self) -> PathBuf {
102        self.objects_path().join("commits")
103    }
104
105    /// path to refs directory
106    pub fn refs_path(&self) -> PathBuf {
107        self.path.join("refs/heads")
108    }
109
110    /// path to tags directory
111    pub fn tags_path(&self) -> PathBuf {
112        self.path.join("refs/tags")
113    }
114
115    /// path to tmp directory (for atomic writes)
116    pub fn tmp_path(&self) -> PathBuf {
117        self.path.join("tmp")
118    }
119
120    /// path to lock file
121    pub fn lock_path(&self) -> PathBuf {
122        self.path.join(".lock")
123    }
124
125    /// acquire exclusive lock on repository
126    /// returns a guard that releases the lock on drop
127    pub fn lock(&self) -> Result<RepoLock> {
128        let lock_path = self.lock_path();
129        let file = File::create(&lock_path).with_path(&lock_path)?;
130
131        let flock = Flock::lock(file, FlockArg::LockExclusiveNonblock)
132            .map_err(|_| Error::LockContention)?;
133
134        Ok(RepoLock { flock })
135    }
136
137    /// try to acquire exclusive lock, returning None if already locked
138    pub fn try_lock(&self) -> Result<Option<RepoLock>> {
139        let lock_path = self.lock_path();
140        let file = File::create(&lock_path).with_path(&lock_path)?;
141
142        match Flock::lock(file, FlockArg::LockExclusiveNonblock) {
143            Ok(flock) => Ok(Some(RepoLock { flock })),
144            Err((_, nix::errno::Errno::EWOULDBLOCK)) => Ok(None),
145            Err(_) => Err(Error::LockContention),
146        }
147    }
148}
149
150/// guard that holds repository lock until dropped
151pub struct RepoLock {
152    #[allow(dead_code)]
153    flock: Flock<File>,
154}
155// lock is released automatically when Flock is dropped
156
157/// helper to run a function while holding the repository lock
158#[allow(dead_code)]
159pub fn with_lock<T, F>(repo: &Repo, f: F) -> Result<T>
160where
161    F: FnOnce() -> Result<T>,
162{
163    let _lock = repo.lock()?;
164    f()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use tempfile::tempdir;
171
172    #[test]
173    fn test_repo_init() {
174        let dir = tempdir().unwrap();
175        let repo_path = dir.path().join("test-repo");
176
177        let repo = Repo::init(&repo_path).unwrap();
178
179        // verify structure
180        assert!(repo_path.join("objects/blobs").is_dir());
181        assert!(repo_path.join("objects/trees").is_dir());
182        assert!(repo_path.join("objects/commits").is_dir());
183        assert!(repo_path.join("refs/heads").is_dir());
184        assert!(repo_path.join("refs/tags").is_dir());
185        assert!(repo_path.join("tmp").is_dir());
186        assert!(repo_path.join("config.toml").is_file());
187
188        // verify namespace was captured
189        assert!(!repo.config().namespace.uid_map.is_empty());
190    }
191
192    #[test]
193    fn test_repo_init_already_exists() {
194        let dir = tempdir().unwrap();
195        let repo_path = dir.path().join("test-repo");
196
197        Repo::init(&repo_path).unwrap();
198        let result = Repo::init(&repo_path);
199
200        assert!(matches!(result, Err(Error::RepoExists(_))));
201    }
202
203    #[test]
204    fn test_repo_open() {
205        let dir = tempdir().unwrap();
206        let repo_path = dir.path().join("test-repo");
207
208        Repo::init(&repo_path).unwrap();
209        let repo = Repo::open(&repo_path).unwrap();
210
211        assert_eq!(repo.path(), repo_path);
212    }
213
214    #[test]
215    fn test_repo_open_not_found() {
216        let dir = tempdir().unwrap();
217        let repo_path = dir.path().join("nonexistent");
218
219        let result = Repo::open(&repo_path);
220        assert!(matches!(result, Err(Error::NoRepo(_))));
221    }
222
223    #[test]
224    fn test_repo_paths() {
225        let dir = tempdir().unwrap();
226        let repo_path = dir.path().join("test-repo");
227        let repo = Repo::init(&repo_path).unwrap();
228
229        assert_eq!(repo.blobs_path(), repo_path.join("objects/blobs"));
230        assert_eq!(repo.trees_path(), repo_path.join("objects/trees"));
231        assert_eq!(repo.commits_path(), repo_path.join("objects/commits"));
232        assert_eq!(repo.refs_path(), repo_path.join("refs/heads"));
233        assert_eq!(repo.tmp_path(), repo_path.join("tmp"));
234    }
235
236    #[test]
237    fn test_repo_lock() {
238        let dir = tempdir().unwrap();
239        let repo_path = dir.path().join("test-repo");
240        let repo = Repo::init(&repo_path).unwrap();
241
242        // acquire lock
243        let lock = repo.lock().unwrap();
244
245        // try to acquire again should fail
246        let result = repo.try_lock().unwrap();
247        assert!(result.is_none());
248
249        // drop lock
250        drop(lock);
251
252        // now should succeed
253        let lock2 = repo.try_lock().unwrap();
254        assert!(lock2.is_some());
255    }
256
257    #[test]
258    fn test_config_modification() {
259        let dir = tempdir().unwrap();
260        let repo_path = dir.path().join("test-repo");
261        let mut repo = Repo::init(&repo_path).unwrap();
262
263        repo.config_mut()
264            .add_remote("origin", "ssh://server/repo")
265            .unwrap();
266        repo.save_config().unwrap();
267
268        // reopen and verify
269        let repo2 = Repo::open(&repo_path).unwrap();
270        assert_eq!(repo2.config().remotes.len(), 1);
271        assert_eq!(repo2.config().remotes[0].name, "origin");
272    }
273}