Skip to main content

krypt_core/
update.rs

1//! Orchestration for `krypt update`.
2//!
3//! Fetches the dotfiles repo from origin (HTTPS only — gix 0.83 has no SSH
4//! transport; see the follow-up issue for the tracking item), fast-forward-
5//! advances the local branch, updates the working tree, then re-runs `link`
6//! to deploy any new files.
7//!
8//! A dirty working tree is always an error: commit, stash, or discard changes
9//! before running `krypt update`.  Auto-stash was removed pending gix gaining
10//! stash support; see the follow-up issue for re-adding it.
11//!
12//! # HTTPS-only note
13//!
14//! gix 0.83 does not have an SSH transport, so only HTTPS URLs are supported.
15//! SSH-based remote URLs will fail with a connection error from gix.  This
16//! limitation will be lifted once gitoxide ships SSH support.
17
18// `UpdateError` wraps gix errors (already boxed) and `ToolConfigError`;
19// on Windows the combined enum exceeds clippy's 128-byte threshold.
20// The variants are already as compact as the upstream types allow.
21#![allow(clippy::result_large_err)]
22
23use std::path::{Path, PathBuf};
24use std::sync::atomic::AtomicBool;
25
26use thiserror::Error;
27
28use crate::deploy::{DeployError, DeployOpts, LinkReport, link};
29use crate::tool_config::{ToolConfig, ToolConfigError};
30
31// ─── Errors ─────────────────────────────────────────────────────────────────
32
33/// Errors from [`update`].
34#[derive(Debug, Error)]
35pub enum UpdateError {
36    /// Tool config is missing — the user needs to run `krypt init` first.
37    #[error("tool config not found at {path:?} — run `krypt init` first")]
38    ToolConfigMissing {
39        /// Path that was checked.
40        path: PathBuf,
41    },
42
43    /// Loading the tool config failed.
44    #[error("loading tool config: {0}")]
45    ToolConfig(#[from] ToolConfigError),
46
47    /// The working tree has uncommitted changes.
48    ///
49    /// A dirty dotfiles repo before `krypt update` is a smell, not a normal
50    /// state.  The right answer is to commit, stash, or discard changes first.
51    /// Auto-stash was removed while gix lacks a stash API; when gix ships
52    /// stash support the auto-stash flow with a `--no-stash` opt-out will be
53    /// restored.
54    #[error(
55        "working tree has uncommitted changes — commit, stash, or discard them \
56         and re-run `krypt update`"
57    )]
58    DirtyWorkingTree,
59
60    /// Opening the git repository failed.
61    #[error("opening git repo at {path:?}: {source}")]
62    OpenRepo {
63        /// Path that was opened.
64        path: PathBuf,
65        /// Underlying gix error (boxed to keep the enum variant small).
66        #[source]
67        source: Box<gix::open::Error>,
68    },
69
70    /// Checking dirty status failed.
71    #[error("checking git status: {0}")]
72    GitStatus(#[source] Box<gix::status::is_dirty::Error>),
73
74    /// No default remote found.
75    #[error("no default fetch remote configured in {path:?}")]
76    NoRemote {
77        /// The repo path.
78        path: PathBuf,
79    },
80
81    /// Connecting to the remote failed.
82    #[error("connecting to remote: {0}")]
83    Connect(#[source] Box<gix::remote::connect::Error>),
84
85    /// Preparing the fetch failed.
86    #[error("preparing fetch: {0}")]
87    PrepareFetch(#[source] Box<gix::remote::fetch::prepare::Error>),
88
89    /// The fetch itself failed.
90    #[error("fetching from remote: {0}")]
91    Fetch(#[source] Box<gix::remote::fetch::Error>),
92
93    /// HEAD is detached (or the operation that needs HEAD failed).
94    #[error("HEAD is detached or could not be resolved — cannot fast-forward")]
95    DetachedHead,
96
97    /// No remote-tracking ref found for the local branch.
98    #[error("no remote-tracking ref for branch {branch:?}")]
99    NoTrackingRef {
100        /// The local branch name.
101        branch: String,
102    },
103
104    /// Computing the merge-base failed (needed for FF check).
105    #[error("merge-base computation: {0}")]
106    MergeBase(#[source] gix::repository::merge_base::Error),
107
108    /// The remote has commits that are not a fast-forward of the local HEAD.
109    #[error("remote is not a fast-forward of local HEAD — cannot pull without merging")]
110    NotFastForward,
111
112    /// Advancing the local branch reference failed.
113    #[error("advancing local branch ref: {0}")]
114    RefEdit(#[source] gix::reference::edit::Error),
115
116    /// Rebuilding the index from the new tree failed.
117    #[error("rebuilding index from new commit tree: {0}")]
118    IndexFromTree(#[source] gix::repository::index_from_tree::Error),
119
120    /// Checking out the new working-tree state failed.
121    #[error("checking out new working tree: {0}")]
122    Checkout(#[source] Box<gix::worktree::state::checkout::Error>),
123
124    /// Writing the updated index to disk failed.
125    #[error("writing index: {0}")]
126    WriteIndex(#[source] gix::index::file::write::Error),
127
128    /// Resolving the checkout options failed.
129    #[error("checkout options: {0}")]
130    CheckoutOptions(#[source] Box<gix::config::checkout_options::Error>),
131
132    /// Converting the object store to an `Arc` failed.
133    #[error("converting object store to Arc: {0}")]
134    OdbArc(#[source] std::io::Error),
135
136    /// Peeling a reference to its target OID failed.
137    #[error("looking up ref OID: {0}")]
138    PeelRef(#[source] gix::reference::peel::Error),
139
140    /// `link` step failed.
141    #[error("deploy link: {0}")]
142    Deploy(#[from] DeployError),
143}
144
145// ─── Options & report ───────────────────────────────────────────────────────
146
147/// Inputs to [`update`].
148///
149/// The working tree **must** be clean before calling `update`.  If it is not,
150/// [`update`] returns [`UpdateError::DirtyWorkingTree`] immediately.
151/// There is no auto-stash option; commit, stash, or discard changes first.
152/// Auto-stash will be re-added once gix gains stash support.
153pub struct UpdateOpts {
154    /// Path to the tool config (`${XDG_CONFIG}/krypt/config.toml`).
155    pub tool_config_path: PathBuf,
156
157    /// Override the path to `.krypt.toml`. Defaults to `<repo_path>/.krypt.toml`.
158    pub config_path: Option<PathBuf>,
159
160    /// Path for the deployment manifest.
161    pub manifest_path: PathBuf,
162
163    /// Pass `dry_run = true` to the link step.
164    pub dry_run: bool,
165
166    /// Documented no-op for forward compatibility (hook runner not yet implemented).
167    pub skip_hooks: bool,
168
169    /// Pass `force = true` to the link step.
170    pub force: bool,
171}
172
173/// Summary returned by a successful [`update`].
174#[derive(Debug)]
175pub struct UpdateReport {
176    /// Whether `git fetch` advanced the repo (i.e. there were new commits).
177    pub pulled: bool,
178
179    /// Report from the `link` step.
180    pub link: LinkReport,
181
182    /// Version warning if our binary is older than `[meta] krypt_min`.
183    pub version_warning: Option<String>,
184
185    /// Number of `post-update` hooks skipped (not yet implemented).
186    pub hooks_skipped: usize,
187}
188
189// ─── Implementation ──────────────────────────────────────────────────────────
190
191/// Pull the dotfiles repo and re-deploy.
192///
193/// Errors immediately if the working tree is dirty.  There is no auto-stash;
194/// that feature was removed pending gix gaining stash support.
195pub fn update(opts: &UpdateOpts) -> Result<UpdateReport, UpdateError> {
196    let tool_cfg = ToolConfig::load(&opts.tool_config_path)?.ok_or_else(|| {
197        UpdateError::ToolConfigMissing {
198            path: opts.tool_config_path.clone(),
199        }
200    })?;
201
202    let repo_path = &tool_cfg.repo.path;
203    let config_path = opts
204        .config_path
205        .clone()
206        .unwrap_or_else(|| repo_path.join(".krypt.toml"));
207
208    let pulled = gix_ff_pull(repo_path)?;
209
210    let krypt_cfg = crate::include::load_with_includes(&config_path).ok();
211
212    let version_warning = krypt_cfg
213        .as_ref()
214        .and_then(|c| c.meta.krypt_min.as_deref())
215        .and_then(version_warning_if_older);
216
217    let hooks_skipped = krypt_cfg
218        .as_ref()
219        .map(|c| c.hooks.iter().filter(|h| h.when == "post-update").count())
220        .unwrap_or(0);
221
222    let link_report = link(&DeployOpts {
223        config_path,
224        manifest_path: opts.manifest_path.clone(),
225        platform: None,
226        dry_run: opts.dry_run,
227        force: opts.force,
228    })?;
229
230    Ok(UpdateReport {
231        pulled,
232        link: link_report,
233        version_warning,
234        hooks_skipped,
235    })
236}
237
238// ─── Internals ───────────────────────────────────────────────────────────────
239
240/// Open the repo, check it is clean, fetch from origin, and fast-forward the
241/// local branch to the remote-tracking commit.
242///
243/// Returns `true` if new commits were received, `false` if already up to date.
244///
245/// # Why not shell out to `git pull --ff-only`?
246///
247/// We use gix as the sole git backend (no process spawning, no libgit2) so
248/// the binary has zero runtime dependency on a system `git` and links only
249/// rustls — no OpenSSL, no libssh2.  The trade-off is that we must implement
250/// the pull logic ourselves:
251///
252/// 1. `repo.is_dirty()` — bail if uncommitted changes exist.
253/// 2. `remote.connect(Fetch).prepare_fetch().receive()` — download new objects
254///    and update `refs/remotes/origin/<branch>`.
255/// 3. Confirm `merge_base(HEAD, remote_tracking) == HEAD` — i.e. remote is
256///    strictly ahead (fast-forward safe).
257/// 4. Advance the local branch ref and check out the new tree.
258///
259/// gix 0.83 has no stash API, so auto-stash was removed; see the follow-up
260/// issue to restore it once gitoxide ships stash support.
261fn gix_ff_pull(repo_path: &Path) -> Result<bool, UpdateError> {
262    let repo = gix::open(repo_path).map_err(|e| UpdateError::OpenRepo {
263        path: repo_path.to_path_buf(),
264        source: Box::new(e),
265    })?;
266
267    // ── 1. Dirty check ───────────────────────────────────────────────────────
268    if repo
269        .is_dirty()
270        .map_err(|e| UpdateError::GitStatus(Box::new(e)))?
271    {
272        return Err(UpdateError::DirtyWorkingTree);
273    }
274
275    // ── 2. Fetch from the default remote ────────────────────────────────────
276    let interrupt = AtomicBool::new(false);
277
278    let remote = repo
279        .find_default_remote(gix::remote::Direction::Fetch)
280        .ok_or_else(|| UpdateError::NoRemote {
281            path: repo_path.to_path_buf(),
282        })?
283        .map_err(|_| UpdateError::NoRemote {
284            path: repo_path.to_path_buf(),
285        })?;
286
287    remote
288        .connect(gix::remote::Direction::Fetch)
289        .map_err(|e| UpdateError::Connect(Box::new(e)))?
290        .prepare_fetch(gix::progress::Discard, Default::default())
291        .map_err(|e| UpdateError::PrepareFetch(Box::new(e)))?
292        .receive(gix::progress::Discard, &interrupt)
293        .map_err(|e| UpdateError::Fetch(Box::new(e)))?;
294
295    // ── 3. Resolve local branch and remote-tracking ref ──────────────────────
296    let head_ref = repo
297        .head_ref()
298        .map_err(|_| UpdateError::DetachedHead)?
299        .ok_or(UpdateError::DetachedHead)?;
300
301    let tracking_name = repo
302        .branch_remote_tracking_ref_name(head_ref.name(), gix::remote::Direction::Fetch)
303        .ok_or_else(|| UpdateError::NoTrackingRef {
304            branch: head_ref.name().shorten().to_string(),
305        })?
306        .map_err(|_| UpdateError::NoTrackingRef {
307            branch: head_ref.name().shorten().to_string(),
308        })?;
309
310    let mut tracking_ref =
311        repo.find_reference(tracking_name.as_ref())
312            .map_err(|_| UpdateError::NoTrackingRef {
313                branch: head_ref.name().shorten().to_string(),
314            })?;
315
316    let new_oid = tracking_ref
317        .peel_to_id()
318        .map_err(UpdateError::PeelRef)?
319        .detach();
320
321    // ── 4. Already up to date? ───────────────────────────────────────────────
322    let head_oid = repo
323        .head_id()
324        .map_err(|_| UpdateError::DetachedHead)?
325        .detach();
326
327    if head_oid == new_oid {
328        return Ok(false);
329    }
330
331    // ── 5. Fast-forward check ────────────────────────────────────────────────
332    //
333    // A fast-forward is safe iff the current HEAD is an ancestor of the new
334    // remote commit, i.e. merge_base(HEAD, new) == HEAD.
335    let base = repo
336        .merge_base(head_oid, new_oid)
337        .map_err(UpdateError::MergeBase)?
338        .detach();
339
340    if base != head_oid {
341        return Err(UpdateError::NotFastForward);
342    }
343
344    // ── 6. Advance the local branch ref ──────────────────────────────────────
345    use gix::refs::{
346        Target,
347        transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog},
348    };
349
350    repo.edit_reference(RefEdit {
351        change: Change::Update {
352            log: LogChange {
353                mode: RefLog::AndReference,
354                force_create_reflog: false,
355                message: "krypt update: fast-forward".into(),
356            },
357            expected: PreviousValue::MustExistAndMatch(Target::Object(head_oid)),
358            new: Target::Object(new_oid),
359        },
360        name: head_ref.name().to_owned(),
361        deref: false,
362    })
363    .map_err(UpdateError::RefEdit)?;
364
365    // ── 7. Update the working tree to match the new commit ───────────────────
366    //
367    // The working tree is guaranteed clean (step 1), so rebuilding the index
368    // from the new tree and checking out is equivalent to `git reset --hard`.
369    // Files removed from the new tree must be explicitly unlinked: we compare
370    // the old and new indices and delete anything that disappeared.
371    let new_commit = repo
372        .find_object(new_oid)
373        .map_err(|_| UpdateError::DetachedHead)?;
374    let new_tree = new_commit
375        .peel_to_tree()
376        .map_err(|_| UpdateError::DetachedHead)?;
377    let new_tree_id = new_tree.id;
378
379    // Build new index from new tree (high-level helper on Repository).
380    let mut new_index = repo
381        .index_from_tree(new_tree_id.as_ref())
382        .map_err(UpdateError::IndexFromTree)?;
383
384    let new_paths: std::collections::HashSet<Vec<u8>> = new_index
385        .entries()
386        .iter()
387        .map(|e| {
388            let p: &[u8] = e.path(&new_index);
389            p.to_vec()
390        })
391        .collect();
392
393    // Load the previous index to discover deleted files.
394    let old_index = repo
395        .index_or_load_from_head()
396        .map_err(|_| UpdateError::DetachedHead)?;
397
398    let workdir = repo.workdir().ok_or(UpdateError::DetachedHead)?;
399
400    for entry in old_index.entries() {
401        let rel: &[u8] = entry.path(&old_index);
402        if !new_paths.contains(rel)
403            && let Ok(rel_str) = std::str::from_utf8(rel)
404        {
405            let _ = std::fs::remove_file(workdir.join(std::path::Path::new(rel_str)));
406        }
407    }
408
409    // Check out the new index into the working directory.
410    let checkout_opts = repo
411        .checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)
412        .map_err(|e| UpdateError::CheckoutOptions(Box::new(e)))?;
413
414    let interrupt2 = AtomicBool::new(false);
415    let files = gix::progress::Discard;
416    let bytes = gix::progress::Discard;
417
418    gix::worktree::state::checkout(
419        &mut new_index,
420        workdir,
421        repo.objects
422            .clone()
423            .into_arc()
424            .map_err(UpdateError::OdbArc)?,
425        &files,
426        &bytes,
427        &interrupt2,
428        checkout_opts,
429    )
430    .map_err(|e| UpdateError::Checkout(Box::new(e)))?;
431
432    new_index
433        .write(Default::default())
434        .map_err(UpdateError::WriteIndex)?;
435
436    Ok(true)
437}
438
439/// Returns a warning string when our binary version is older than `min_version`.
440fn version_warning_if_older(min_version: &str) -> Option<String> {
441    let our_version = env!("CARGO_PKG_VERSION");
442    if version_less_than(our_version, min_version) {
443        Some(format!(
444            "warning: this repo requires krypt >= {min_version}, but you have {our_version}; \
445             please upgrade"
446        ))
447    } else {
448        None
449    }
450}
451
452/// Returns true if `a` is strictly less than `b` using semver-style comparison.
453///
454/// Parsing failures fall through to lexicographic comparison so the binary
455/// never hard-fails on a malformed `krypt_min` value.
456fn version_less_than(a: &str, b: &str) -> bool {
457    match (parse_version(a), parse_version(b)) {
458        (Some(av), Some(bv)) => av < bv,
459        _ => a < b,
460    }
461}
462
463/// Parse a `MAJOR.MINOR.PATCH` string into a comparable tuple.
464fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
465    let mut parts = v.splitn(3, '.');
466    let major = parts.next()?.parse().ok()?;
467    let minor = parts.next()?.parse().ok()?;
468    let patch = parts
469        .next()?
470        .trim_end_matches(|c: char| !c.is_ascii_digit())
471        .parse()
472        .ok()?;
473    Some((major, minor, patch))
474}
475
476// ─── Tests ──────────────────────────────────────────────────────────────────
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use std::fs;
482    use tempfile::tempdir;
483
484    // ── gix test helpers ────────────────────────────────────────────────────
485
486    fn test_sig_raw() -> &'static str {
487        // Raw git signature format: "Name <email> seconds tz"
488        "Test <test@test.test> 0 +0000"
489    }
490
491    /// Write a commit directly via gix's high-level `commit_as` API.
492    fn write_commit(repo: &gix::Repository, message: &str, files: &[(&str, &[u8])]) {
493        // Build and write blob objects, then build tree.
494        let mut tree_entries: Vec<gix::objs::tree::Entry> = files
495            .iter()
496            .map(|(name, content)| {
497                let blob_id = repo.write_blob(content).expect("write blob").detach();
498                gix::objs::tree::Entry {
499                    mode: gix::objs::tree::EntryKind::Blob.into(),
500                    filename: (*name).into(),
501                    oid: blob_id,
502                }
503            })
504            .collect();
505        tree_entries.sort_by(|a, b| a.filename.cmp(&b.filename));
506
507        let tree = gix::objs::Tree {
508            entries: tree_entries,
509        };
510        let tree_id = repo.write_object(&tree).expect("write tree").detach();
511
512        let sig = gix::actor::SignatureRef::from_bytes(test_sig_raw().as_bytes())
513            .expect("valid test sig");
514        let parent: Vec<gix::hash::ObjectId> = repo
515            .head_id()
516            .ok()
517            .map(|id| id.detach())
518            .into_iter()
519            .collect();
520
521        // commit_as updates HEAD automatically (deref through symbolic HEAD).
522        repo.commit_as(sig, sig, "HEAD", message, tree_id, parent)
523            .expect("write commit");
524    }
525
526    /// Init a new repo with a single empty commit.
527    fn init_with_commit(dir: &Path) -> gix::Repository {
528        let repo = gix::init(dir).expect("gix::init");
529        write_commit(&repo, "initial", &[]);
530        repo
531    }
532
533    fn make_tool_config(repo_path: &Path, tc_dir: &tempfile::TempDir) -> PathBuf {
534        let tc_path = tc_dir.path().join("krypt").join("config.toml");
535        let cfg = crate::tool_config::ToolConfig {
536            repo: crate::tool_config::RepoConfig {
537                path: repo_path.to_path_buf(),
538                url: None,
539            },
540        };
541        cfg.save(&tc_path).unwrap();
542        tc_path
543    }
544
545    // ── Tests ────────────────────────────────────────────────────────────────
546
547    /// A modified index entry (tree-vs-index mismatch) causes `DirtyWorkingTree`.
548    ///
549    /// gix's `is_dirty()` does not flag *untracked* files (matching git's
550    /// `--ignore-untracked` semantics).  For a dotfiles repo this is correct:
551    /// a stray untracked file in the repo root should not block a pull.
552    ///
553    /// We trigger a tree-vs-index mismatch by staging a blob that is different
554    /// from what the HEAD commit contains.
555    #[test]
556    fn dirty_tree_always_errors() {
557        let local = tempdir().unwrap();
558
559        // Commit a tracked file.
560        write_commit(
561            &init_with_commit(local.path()),
562            "add file",
563            &[("tracked.txt", b"original")],
564        );
565
566        // Make the index dirty: write the file with different content to disk
567        // AND update the index to point to a blob with different content than
568        // the HEAD tree has.  We do this by staging via gix's index APIs.
569        //
570        // The simplest approach: after commit, the index (if it exists on disk)
571        // should match HEAD.  We rebuild it from the current HEAD tree, then
572        // write different content to disk so that the index SHA != worktree SHA.
573        {
574            let repo = gix::open(local.path()).expect("open");
575            let head_tree_id = repo
576                .head_commit()
577                .expect("head commit")
578                .tree_id()
579                .expect("tree");
580            let mut idx = repo
581                .index_from_tree(head_tree_id.as_ref())
582                .expect("index from tree");
583            // Write the index to disk so gix can compare it with the worktree.
584            idx.write(Default::default()).expect("write index");
585        }
586        // Now modify the file on disk so it differs from what the index records.
587        fs::write(local.path().join("tracked.txt"), b"modified").unwrap();
588
589        let tc_dir = tempdir().unwrap();
590        let tc_path = make_tool_config(local.path(), &tc_dir);
591        let state = tempdir().unwrap();
592
593        let err = update(&UpdateOpts {
594            tool_config_path: tc_path,
595            config_path: Some(local.path().join(".krypt.toml")),
596            manifest_path: state.path().join("manifest.json"),
597            dry_run: false,
598            skip_hooks: false,
599            force: false,
600        })
601        .unwrap_err();
602
603        assert!(
604            matches!(err, UpdateError::DirtyWorkingTree),
605            "expected DirtyWorkingTree, got {err:?}"
606        );
607    }
608
609    #[test]
610    fn tool_config_missing_gives_clear_error() {
611        let tc_dir = tempdir().unwrap();
612        let tc_path = tc_dir.path().join("nonexistent.toml");
613        let state = tempdir().unwrap();
614
615        let err = update(&UpdateOpts {
616            tool_config_path: tc_path.clone(),
617            config_path: None,
618            manifest_path: state.path().join("manifest.json"),
619            dry_run: false,
620            skip_hooks: false,
621            force: false,
622        })
623        .unwrap_err();
624
625        assert!(
626            matches!(err, UpdateError::ToolConfigMissing { ref path } if path == &tc_path),
627            "expected ToolConfigMissing, got {err:?}"
628        );
629    }
630
631    #[test]
632    fn version_warning_fires_when_older() {
633        assert!(version_less_than("0.0.2", "99.0.0"));
634        let warn = version_warning_if_older("99.0.0");
635        assert!(warn.is_some());
636        assert!(warn.unwrap().contains("99.0.0"));
637    }
638
639    #[test]
640    fn version_warning_absent_when_current() {
641        let our = env!("CARGO_PKG_VERSION");
642        assert!(version_warning_if_older(our).is_none());
643    }
644
645    #[test]
646    fn parse_version_basic() {
647        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
648        assert_eq!(parse_version("0.0.0"), Some((0, 0, 0)));
649        assert!(parse_version("bad").is_none());
650    }
651}