1#![allow(clippy::result_large_err)]
18
19use std::fs;
20use std::io;
21use std::path::{Path, PathBuf};
22use std::sync::atomic::AtomicBool;
23
24use thiserror::Error;
25
26use crate::tool_config::{RepoConfig, ToolConfig, ToolConfigError};
27
28#[derive(Debug, Error)]
32pub enum InitError {
33 #[error("must provide URL or --bare")]
35 MissingUrl,
36
37 #[error("repo path already exists: {path:?} (use --force to overwrite)")]
39 RepoExists {
40 path: PathBuf,
42 },
43
44 #[error("preparing clone of {url:?}: {source}")]
46 GixClonePrepare {
47 url: String,
49 #[source]
51 source: Box<gix::clone::Error>,
52 },
53
54 #[error("fetching during clone of {url:?}: {source}")]
56 GixCloneFetch {
57 url: String,
59 #[source]
61 source: Box<gix::clone::fetch::Error>,
62 },
63
64 #[error("checking out clone of {url:?}: {source}")]
66 GixCloneCheckout {
67 url: String,
69 #[source]
71 source: Box<gix::clone::checkout::main_worktree::Error>,
72 },
73
74 #[error("io: {0}")]
76 Io(#[from] io::Error),
77
78 #[error("writing tool config: {0}")]
80 ToolConfig(#[from] ToolConfigError),
81}
82
83pub struct InitOpts {
87 pub url: Option<String>,
92 pub repo_path: PathBuf,
94 pub tool_config_path: PathBuf,
96 pub bare: bool,
98 pub force: bool,
100}
101
102#[derive(Debug)]
104pub struct InitReport {
105 pub repo_path: PathBuf,
107 pub tool_config_path: PathBuf,
109}
110
111pub fn init(opts: &InitOpts) -> Result<InitReport, InitError> {
115 if opts.url.is_none() && !opts.bare {
116 return Err(InitError::MissingUrl);
117 }
118
119 prepare_repo_path(&opts.repo_path, opts.force)?;
120
121 if opts.bare {
122 init_bare(&opts.repo_path)?;
123 } else {
124 gix_clone(opts.url.as_deref().unwrap(), &opts.repo_path)?;
125 }
126
127 let cfg = ToolConfig {
128 repo: RepoConfig {
129 path: opts.repo_path.clone(),
130 url: if opts.bare { None } else { opts.url.clone() },
131 },
132 };
133 cfg.save(&opts.tool_config_path)?;
134
135 Ok(InitReport {
136 repo_path: opts.repo_path.clone(),
137 tool_config_path: opts.tool_config_path.clone(),
138 })
139}
140
141fn prepare_repo_path(path: &Path, force: bool) -> Result<(), InitError> {
144 if path.exists() {
145 let non_empty = path
146 .read_dir()
147 .map(|mut d| d.next().is_some())
148 .unwrap_or(false);
149 if non_empty {
150 if force {
151 fs::remove_dir_all(path)?;
152 } else {
153 return Err(InitError::RepoExists {
154 path: path.to_path_buf(),
155 });
156 }
157 }
158 }
159 Ok(())
160}
161
162fn gix_clone(url: &str, dest: &Path) -> Result<(), InitError> {
167 let interrupt = AtomicBool::new(false);
168
169 let (mut checkout, _fetch_outcome) = gix::prepare_clone(url, dest)
170 .map_err(|e| InitError::GixClonePrepare {
171 url: url.to_owned(),
172 source: Box::new(e),
173 })?
174 .fetch_then_checkout(gix::progress::Discard, &interrupt)
175 .map_err(|e| InitError::GixCloneFetch {
176 url: url.to_owned(),
177 source: Box::new(e),
178 })?;
179
180 checkout
181 .main_worktree(gix::progress::Discard, &interrupt)
182 .map_err(|e| InitError::GixCloneCheckout {
183 url: url.to_owned(),
184 source: Box::new(e),
185 })?;
186 Ok(())
189}
190
191fn init_bare(path: &Path) -> Result<(), InitError> {
192 fs::create_dir_all(path)?;
193 let stub = path.join(".krypt.toml");
194 fs::write(
195 &stub,
196 concat!(
197 "# krypt dotfiles manifest\n",
198 "# See https://github.com/kryptic-sh/krypt for schema reference.\n",
199 "\n",
200 "# [[link]]\n",
201 "# src = \".gitconfig\"\n",
202 "# dst = \"${HOME}/.gitconfig\"\n",
203 ),
204 )?;
205 Ok(())
206}
207
208#[cfg(test)]
211mod tests {
212 use super::*;
213 use tempfile::tempdir;
214
215 fn tool_config_path(dir: &tempfile::TempDir) -> PathBuf {
216 dir.path().join("krypt").join("config.toml")
217 }
218
219 #[test]
220 fn missing_url_no_bare_errors() {
221 let repo_dir = tempdir().unwrap();
222 let cfg_dir = tempdir().unwrap();
223 let err = init(&InitOpts {
224 url: None,
225 repo_path: repo_dir.path().join("repo"),
226 tool_config_path: tool_config_path(&cfg_dir),
227 bare: false,
228 force: false,
229 })
230 .unwrap_err();
231 assert!(matches!(err, InitError::MissingUrl));
232 }
233
234 #[test]
235 fn bare_creates_stub_and_tool_config() {
236 let repo_dir = tempdir().unwrap();
237 let cfg_dir = tempdir().unwrap();
238 let repo_path = repo_dir.path().join("repo");
239 let tc_path = tool_config_path(&cfg_dir);
240
241 init(&InitOpts {
242 url: None,
243 repo_path: repo_path.clone(),
244 tool_config_path: tc_path.clone(),
245 bare: true,
246 force: false,
247 })
248 .unwrap();
249
250 assert!(repo_path.join(".krypt.toml").exists());
251 let tc = ToolConfig::load(&tc_path).unwrap().unwrap();
252 assert_eq!(tc.repo.path, repo_path);
253 assert!(tc.repo.url.is_none());
254 }
255
256 #[test]
257 fn existing_repo_without_force_errors() {
258 let repo_dir = tempdir().unwrap();
259 let cfg_dir = tempdir().unwrap();
260 let repo_path = repo_dir.path().join("repo");
261 fs::create_dir_all(&repo_path).unwrap();
262 fs::write(repo_path.join("existing"), b"data").unwrap();
263
264 let err = init(&InitOpts {
265 url: None,
266 repo_path,
267 tool_config_path: tool_config_path(&cfg_dir),
268 bare: true,
269 force: false,
270 })
271 .unwrap_err();
272 assert!(matches!(err, InitError::RepoExists { .. }));
273 }
274
275 #[test]
276 fn existing_repo_with_force_succeeds() {
277 let repo_dir = tempdir().unwrap();
278 let cfg_dir = tempdir().unwrap();
279 let repo_path = repo_dir.path().join("repo");
280 fs::create_dir_all(&repo_path).unwrap();
281 fs::write(repo_path.join("old_file"), b"old").unwrap();
282
283 init(&InitOpts {
284 url: None,
285 repo_path: repo_path.clone(),
286 tool_config_path: tool_config_path(&cfg_dir),
287 bare: true,
288 force: true,
289 })
290 .unwrap();
291
292 assert!(!repo_path.join("old_file").exists());
293 assert!(repo_path.join(".krypt.toml").exists());
294 }
295
296 #[test]
301 fn clone_from_local_file_url() {
302 let origin_dir = tempdir().unwrap();
303 let repo_dir = tempdir().unwrap();
304 let cfg_dir = tempdir().unwrap();
305
306 let origin_repo = gix::init(origin_dir.path()).expect("gix::init origin");
308 write_initial_gix_commit(&origin_repo);
309
310 let url = format!("file://{}", origin_dir.path().display());
311 let repo_path = repo_dir.path().join("repo");
312 let tc_path = tool_config_path(&cfg_dir);
313
314 init(&InitOpts {
315 url: Some(url.clone()),
316 repo_path: repo_path.clone(),
317 tool_config_path: tc_path.clone(),
318 bare: false,
319 force: false,
320 })
321 .unwrap();
322
323 assert!(repo_path.exists());
324 let tc = ToolConfig::load(&tc_path).unwrap().unwrap();
325 assert_eq!(tc.repo.path, repo_path);
326 assert_eq!(tc.repo.url.as_deref(), Some(url.as_str()));
327 }
328
329 fn write_initial_gix_commit(repo: &gix::Repository) {
331 let sig = gix::actor::SignatureRef::from_bytes(b"Test <test@test.test> 0 +0000")
332 .expect("valid sig");
333
334 let empty_tree = gix::objs::Tree::empty();
335 let tree_id = repo.write_object(&empty_tree).expect("write tree").detach();
336
337 let parents: Vec<gix::hash::ObjectId> = vec![];
339 repo.commit_as(sig, sig, "HEAD", "initial", tree_id, parents)
340 .expect("write initial commit");
341 }
342}