Skip to main content

krypt_core/
init.rs

1//! Orchestration for `krypt init`.
2//!
3//! Clones a remote dotfiles repo (or creates an empty local stub) into
4//! the configured repo path, then writes the tool config.
5//!
6//! # HTTPS-only note
7//!
8//! Cloning uses gix's blocking HTTP transport with rustls — no system `git`
9//! required, no OpenSSL, no libssh2.  The trade-off is that **only HTTPS
10//! URLs are supported** (gix 0.83 has no SSH transport).  If your remote is
11//! SSH-only, clone manually with `git clone` first and then run
12//! `krypt init --repo-path <path>` without a URL to write the tool config.
13
14// `InitError` wraps gix clone/checkout errors (already boxed) and
15// `ToolConfigError`; on Windows the combined enum exceeds clippy's 128-byte
16// threshold.  The variants are already as compact as the upstream types allow.
17#![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// ─── Errors ─────────────────────────────────────────────────────────────────
29
30/// Errors from [`init`].
31#[derive(Debug, Error)]
32pub enum InitError {
33    /// No URL supplied and `--bare` not set.
34    #[error("must provide URL or --bare")]
35    MissingUrl,
36
37    /// Repo path already exists (non-empty) and `--force` was not set.
38    #[error("repo path already exists: {path:?} (use --force to overwrite)")]
39    RepoExists {
40        /// The conflicting path.
41        path: PathBuf,
42    },
43
44    /// `gix::prepare_clone` failed.
45    #[error("preparing clone of {url:?}: {source}")]
46    GixClonePrepare {
47        /// The URL that was cloned.
48        url: String,
49        /// Underlying error (boxed to keep the enum variant small).
50        #[source]
51        source: Box<gix::clone::Error>,
52    },
53
54    /// The fetch step of the clone failed.
55    #[error("fetching during clone of {url:?}: {source}")]
56    GixCloneFetch {
57        /// The URL that was cloned.
58        url: String,
59        /// Underlying error (boxed to keep the enum variant small).
60        #[source]
61        source: Box<gix::clone::fetch::Error>,
62    },
63
64    /// The checkout step after cloning failed.
65    #[error("checking out clone of {url:?}: {source}")]
66    GixCloneCheckout {
67        /// The URL that was cloned.
68        url: String,
69        /// Underlying error (boxed to keep the enum variant small).
70        #[source]
71        source: Box<gix::clone::checkout::main_worktree::Error>,
72    },
73
74    /// I/O failure outside git (directory creation, file write, …).
75    #[error("io: {0}")]
76    Io(#[from] io::Error),
77
78    /// Writing the tool config failed.
79    #[error("writing tool config: {0}")]
80    ToolConfig(#[from] ToolConfigError),
81}
82
83// ─── Options & report ───────────────────────────────────────────────────────
84
85/// Inputs to [`init`].
86pub struct InitOpts {
87    /// Remote HTTPS URL to clone from.  `None` when `--bare`.
88    ///
89    /// Only HTTPS URLs are supported by the gix transport used here.
90    /// SSH URLs will fail — see the module-level note.
91    pub url: Option<String>,
92    /// Where to put the repo on disk.
93    pub repo_path: PathBuf,
94    /// Where to write `config.toml`.
95    pub tool_config_path: PathBuf,
96    /// Create an empty stub instead of cloning.
97    pub bare: bool,
98    /// Wipe an existing repo path before proceeding.
99    pub force: bool,
100}
101
102/// Summary returned by a successful [`init`].
103#[derive(Debug)]
104pub struct InitReport {
105    /// Absolute path to the repo on disk.
106    pub repo_path: PathBuf,
107    /// Absolute path to the tool config written.
108    pub tool_config_path: PathBuf,
109}
110
111// ─── Implementation ──────────────────────────────────────────────────────────
112
113/// Initialise a dotfiles repo and write the tool config.
114pub 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
141// ─── Internals ───────────────────────────────────────────────────────────────
142
143fn 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
162/// Clone `url` into `dest` using gix's blocking HTTP/rustls transport.
163///
164/// No system `git` binary is required.  Only HTTPS URLs are supported —
165/// gix 0.83 has no SSH transport.
166fn 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    // (Repository and checkout outcome returned; we only need the side-effect.)
187
188    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// ─── Tests ──────────────────────────────────────────────────────────────────
209
210#[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    /// Clone from a local file-path URL using gix.
297    ///
298    /// We need a non-empty commit in the origin so that gix has something to
299    /// check out — an empty bare repo will succeed but produce an empty clone.
300    #[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        // Create a local non-bare repo with an initial commit using gix APIs.
307        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    /// Write an initial empty commit to a freshly initialised gix repository.
330    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        // commit_as creates the commit and advances HEAD.
338        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}