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::config::Config;
29use crate::deploy::{DeployError, DeployOpts, LinkReport, link};
30use crate::predicate::{DefaultPredicateEnv, default_predicate_evaluator, eval};
31use crate::runner::{Context, Notifier, ProcessExec, Prompter, RunnerError, execute_hook};
32use crate::tool_config::{ToolConfig, ToolConfigError};
33
34// ─── Errors ─────────────────────────────────────────────────────────────────
35
36/// Errors from [`update`].
37#[derive(Debug, Error)]
38pub enum UpdateError {
39    /// Tool config is missing — the user needs to run `krypt init` first.
40    #[error("tool config not found at {path:?} — run `krypt init` first")]
41    ToolConfigMissing {
42        /// Path that was checked.
43        path: PathBuf,
44    },
45
46    /// Loading the tool config failed.
47    #[error("loading tool config: {0}")]
48    ToolConfig(#[from] ToolConfigError),
49
50    /// The working tree has uncommitted changes.
51    ///
52    /// A dirty dotfiles repo before `krypt update` is a smell, not a normal
53    /// state.  The right answer is to commit, stash, or discard changes first.
54    /// Auto-stash was removed while gix lacks a stash API; when gix ships
55    /// stash support the auto-stash flow with a `--no-stash` opt-out will be
56    /// restored.
57    #[error(
58        "working tree has uncommitted changes — commit, stash, or discard them \
59         and re-run `krypt update`"
60    )]
61    DirtyWorkingTree,
62
63    /// Opening the git repository failed.
64    #[error("opening git repo at {path:?}: {source}")]
65    OpenRepo {
66        /// Path that was opened.
67        path: PathBuf,
68        /// Underlying gix error (boxed to keep the enum variant small).
69        #[source]
70        source: Box<gix::open::Error>,
71    },
72
73    /// Checking dirty status failed.
74    #[error("checking git status: {0}")]
75    GitStatus(#[source] Box<gix::status::is_dirty::Error>),
76
77    /// No default remote found.
78    #[error("no default fetch remote configured in {path:?}")]
79    NoRemote {
80        /// The repo path.
81        path: PathBuf,
82    },
83
84    /// Connecting to the remote failed.
85    #[error("connecting to remote: {0}")]
86    Connect(#[source] Box<gix::remote::connect::Error>),
87
88    /// Preparing the fetch failed.
89    #[error("preparing fetch: {0}")]
90    PrepareFetch(#[source] Box<gix::remote::fetch::prepare::Error>),
91
92    /// The fetch itself failed.
93    #[error("fetching from remote: {0}")]
94    Fetch(#[source] Box<gix::remote::fetch::Error>),
95
96    /// HEAD is detached (or the operation that needs HEAD failed).
97    #[error("HEAD is detached or could not be resolved — cannot fast-forward")]
98    DetachedHead,
99
100    /// No remote-tracking ref found for the local branch.
101    #[error("no remote-tracking ref for branch {branch:?}")]
102    NoTrackingRef {
103        /// The local branch name.
104        branch: String,
105    },
106
107    /// Computing the merge-base failed (needed for FF check).
108    #[error("merge-base computation: {0}")]
109    MergeBase(#[source] gix::repository::merge_base::Error),
110
111    /// The remote has commits that are not a fast-forward of the local HEAD.
112    #[error("remote is not a fast-forward of local HEAD — cannot pull without merging")]
113    NotFastForward,
114
115    /// Advancing the local branch reference failed.
116    #[error("advancing local branch ref: {0}")]
117    RefEdit(#[source] gix::reference::edit::Error),
118
119    /// Rebuilding the index from the new tree failed.
120    #[error("rebuilding index from new commit tree: {0}")]
121    IndexFromTree(#[source] gix::repository::index_from_tree::Error),
122
123    /// Checking out the new working-tree state failed.
124    #[error("checking out new working tree: {0}")]
125    Checkout(#[source] Box<gix::worktree::state::checkout::Error>),
126
127    /// Writing the updated index to disk failed.
128    #[error("writing index: {0}")]
129    WriteIndex(#[source] gix::index::file::write::Error),
130
131    /// Resolving the checkout options failed.
132    #[error("checkout options: {0}")]
133    CheckoutOptions(#[source] Box<gix::config::checkout_options::Error>),
134
135    /// Converting the object store to an `Arc` failed.
136    #[error("converting object store to Arc: {0}")]
137    OdbArc(#[source] std::io::Error),
138
139    /// Peeling a reference to its target OID failed.
140    #[error("looking up ref OID: {0}")]
141    PeelRef(#[source] gix::reference::peel::Error),
142
143    /// `link` step failed.
144    #[error("deploy link: {0}")]
145    Deploy(#[from] DeployError),
146
147    /// A post-update hook failed and `ignore_failure` was not set.
148    ///
149    /// `RunnerError` is boxed to keep the enum variant ≤ 128 bytes on Windows.
150    #[error("hook {name:?} failed: {source}")]
151    Hook {
152        /// The hook's `name` field.
153        name: String,
154        /// The underlying runner error.
155        #[source]
156        source: Box<RunnerError>,
157    },
158}
159
160// ─── Options & report ───────────────────────────────────────────────────────
161
162/// Inputs to [`update`].
163///
164/// The working tree **must** be clean before calling `update`.  If it is not,
165/// [`update`] returns [`UpdateError::DirtyWorkingTree`] immediately.
166/// There is no auto-stash option; commit, stash, or discard changes first.
167/// Auto-stash will be re-added once gix gains stash support.
168pub struct UpdateOpts {
169    /// Path to the tool config (`${XDG_CONFIG}/krypt/config.toml`).
170    pub tool_config_path: PathBuf,
171
172    /// Override the path to `.krypt.toml`. Defaults to `<repo_path>/.krypt.toml`.
173    pub config_path: Option<PathBuf>,
174
175    /// Path for the deployment manifest.
176    pub manifest_path: PathBuf,
177
178    /// Pass `dry_run = true` to the link step.
179    pub dry_run: bool,
180
181    /// Skip all post-update hooks.
182    pub skip_hooks: bool,
183
184    /// Pass `force = true` to the link step.
185    pub force: bool,
186}
187
188/// Summary of `post-update` hook execution.
189#[derive(Debug, Default)]
190pub struct HookSummary {
191    /// Total `post-update` hooks found in the config.
192    pub total: usize,
193    /// Hooks successfully run to completion.
194    pub ran: usize,
195    /// Hooks skipped because `r#if` predicate evaluated false.
196    pub skipped_by_predicate: usize,
197    /// Hooks skipped because `--skip-hooks` was set.
198    pub skipped_by_flag: usize,
199    /// Hooks that failed but had `ignore_failure: true`.
200    pub failed_ignored: usize,
201    /// Set when `--dry-run` was used; `ran`/`skipped` counters stay 0.
202    pub dry_run: bool,
203}
204
205/// Summary returned by a successful [`update`].
206#[derive(Debug)]
207pub struct UpdateReport {
208    /// Whether `git fetch` advanced the repo (i.e. there were new commits).
209    pub pulled: bool,
210
211    /// Report from the `link` step.
212    pub link: LinkReport,
213
214    /// Version warning if our binary is older than `[meta] krypt_min`.
215    pub version_warning: Option<String>,
216
217    /// Summary of `post-update` hook execution.
218    pub hooks: HookSummary,
219}
220
221// ─── Implementation ──────────────────────────────────────────────────────────
222
223/// Pull the dotfiles repo and re-deploy.
224///
225/// Errors immediately if the working tree is dirty.  There is no auto-stash;
226/// that feature was removed pending gix gaining stash support.
227pub fn update(opts: &UpdateOpts) -> Result<UpdateReport, UpdateError> {
228    let tool_cfg = ToolConfig::load(&opts.tool_config_path)?.ok_or_else(|| {
229        UpdateError::ToolConfigMissing {
230            path: opts.tool_config_path.clone(),
231        }
232    })?;
233
234    let repo_path = &tool_cfg.repo.path;
235    let config_path = opts
236        .config_path
237        .clone()
238        .unwrap_or_else(|| repo_path.join(".krypt.toml"));
239
240    let pulled = gix_ff_pull(repo_path)?;
241
242    let krypt_cfg = crate::include::load_with_includes(&config_path).ok();
243
244    let version_warning = krypt_cfg
245        .as_ref()
246        .and_then(|c| c.meta.krypt_min.as_deref())
247        .and_then(version_warning_if_older);
248
249    let link_report = link(&DeployOpts {
250        config_path,
251        manifest_path: opts.manifest_path.clone(),
252        platform: None,
253        dry_run: opts.dry_run,
254        force: opts.force,
255    })?;
256
257    // Execute post-update hooks using real production dependencies.
258    let notifier = crate::notify::AutoNotifier::new(
259        krypt_cfg
260            .as_ref()
261            .and_then(|c| c.meta.notify_backend.as_deref()),
262    );
263    let mut prompter = crate::runner::RealPrompter;
264    let hooks_summary = run_post_update_hooks_inner(
265        krypt_cfg.as_ref(),
266        opts.skip_hooks,
267        opts.dry_run,
268        &notifier,
269        &mut prompter,
270    )?;
271
272    Ok(UpdateReport {
273        pulled,
274        link: link_report,
275        version_warning,
276        hooks: hooks_summary,
277    })
278}
279
280// ─── Hook runner helper ───────────────────────────────────────────────────────
281
282/// Execute `post-update` hooks from `cfg`.
283///
284/// This inner helper accepts injected dependencies so that tests can supply
285/// `MockProcessExec`, `MockNotifier`, and `MockPrompter` without spinning up a
286/// full git repo.  Production calls this with real implementations.
287///
288/// Returns a [`HookSummary`] on success.  If a hook fails and its
289/// `ignore_failure` is `false`, returns `Err(UpdateError::Hook { ... })` and
290/// stops processing further hooks.
291pub(crate) fn run_post_update_hooks_inner(
292    cfg: Option<&Config>,
293    skip: bool,
294    dry_run: bool,
295    notifier: &dyn Notifier,
296    prompter: &mut dyn Prompter,
297) -> Result<HookSummary, UpdateError> {
298    run_post_update_hooks_with_exec(
299        cfg,
300        skip,
301        dry_run,
302        &crate::runner::RealProcessExec,
303        notifier,
304        prompter,
305    )
306}
307
308/// Same as [`run_post_update_hooks_inner`] but additionally accepts an injected
309/// `ProcessExec` — the seam that test code uses to inject `MockProcessExec`.
310pub(crate) fn run_post_update_hooks_with_exec(
311    cfg: Option<&Config>,
312    skip: bool,
313    dry_run: bool,
314    process: &dyn ProcessExec,
315    notifier: &dyn Notifier,
316    prompter: &mut dyn Prompter,
317) -> Result<HookSummary, UpdateError> {
318    let Some(cfg) = cfg else {
319        return Ok(HookSummary::default());
320    };
321
322    // Only post-update hooks today; future phases will add pre-link / post-link / etc.
323    let post_update_hooks: Vec<_> = cfg
324        .hooks
325        .iter()
326        .filter(|h| h.when == "post-update")
327        .collect();
328
329    let total = post_update_hooks.len();
330    let mut summary = HookSummary {
331        total,
332        dry_run,
333        ..Default::default()
334    };
335
336    if total == 0 {
337        return Ok(summary);
338    }
339
340    // Build predicate evaluator with [paths] overrides from config.
341    let mut resolver = crate::paths::Resolver::new();
342    resolver = resolver.with_overrides(cfg.paths.clone().into_iter().collect());
343    let env = DefaultPredicateEnv::with_resolver(resolver);
344    let eval_predicate = default_predicate_evaluator(env);
345
346    if skip {
347        summary.skipped_by_flag = total;
348        return Ok(summary);
349    }
350
351    if dry_run {
352        // Dry-run: evaluate predicates but don't execute. Print a hook plan.
353        println!("hooks (dry-run):");
354
355        // We need a fresh evaluator for each hook's predicate check in dry-run.
356        // Re-build it since the closure above moved `env`.
357        let mut resolver2 = crate::paths::Resolver::new();
358        resolver2 = resolver2.with_overrides(cfg.paths.clone().into_iter().collect());
359        let env2 = DefaultPredicateEnv::with_resolver(resolver2);
360
361        for hook in &post_update_hooks {
362            let predicate_result = if let Some(ref pred) = hook.r#if {
363                match eval(pred, &env2) {
364                    Ok(true) => "ok",
365                    Ok(false) => "would-skip",
366                    Err(_) => "predicate-error",
367                }
368            } else {
369                "ok"
370            };
371            let run_preview = hook.run.first().map(String::as_str).unwrap_or("<empty>");
372            println!(
373                "  hook {:?}: {} — {}",
374                hook.name, predicate_result, run_preview
375            );
376        }
377        // counters stay 0 in dry-run
378        return Ok(summary);
379    }
380
381    // Live execution.
382    let ctx = Context {
383        captures: std::collections::BTreeMap::new(),
384        args: Vec::new(),
385        stdin: None,
386    };
387
388    for hook in &post_update_hooks {
389        // Evaluate predicate first (skip silently if false).
390        if hook
391            .r#if
392            .as_deref()
393            .is_some_and(|pred| !eval_predicate(pred, &ctx))
394        {
395            summary.skipped_by_predicate += 1;
396            continue;
397        }
398
399        // Execute the hook.
400        match execute_hook(hook, process, notifier, prompter, &eval_predicate) {
401            Ok(report) if report.steps_failed_ignored > 0 => {
402                // The hook's ignore_failure absorbed the error inside the runner.
403                tracing::warn!(
404                    hook = %hook.name,
405                    "post-update hook failed (ignore_failure = true) — continuing"
406                );
407                summary.failed_ignored += 1;
408            }
409            Ok(_) => {
410                summary.ran += 1;
411            }
412            Err(e) => {
413                // ignore_failure = false (the runner would have returned Err only then).
414                return Err(UpdateError::Hook {
415                    name: hook.name.clone(),
416                    source: Box::new(e),
417                });
418            }
419        }
420    }
421
422    Ok(summary)
423}
424
425// ─── Internals ───────────────────────────────────────────────────────────────
426
427/// Open the repo, check it is clean, fetch from origin, and fast-forward the
428/// local branch to the remote-tracking commit.
429///
430/// Returns `true` if new commits were received, `false` if already up to date.
431///
432/// # Why not shell out to `git pull --ff-only`?
433///
434/// We use gix as the sole git backend (no process spawning, no libgit2) so
435/// the binary has zero runtime dependency on a system `git` and links only
436/// rustls — no OpenSSL, no libssh2.  The trade-off is that we must implement
437/// the pull logic ourselves:
438///
439/// 1. `repo.is_dirty()` — bail if uncommitted changes exist.
440/// 2. `remote.connect(Fetch).prepare_fetch().receive()` — download new objects
441///    and update `refs/remotes/origin/<branch>`.
442/// 3. Confirm `merge_base(HEAD, remote_tracking) == HEAD` — i.e. remote is
443///    strictly ahead (fast-forward safe).
444/// 4. Advance the local branch ref and check out the new tree.
445///
446/// gix 0.83 has no stash API, so auto-stash was removed; see the follow-up
447/// issue to restore it once gitoxide ships stash support.
448fn gix_ff_pull(repo_path: &Path) -> Result<bool, UpdateError> {
449    let repo = gix::open(repo_path).map_err(|e| UpdateError::OpenRepo {
450        path: repo_path.to_path_buf(),
451        source: Box::new(e),
452    })?;
453
454    // ── 1. Dirty check ───────────────────────────────────────────────────────
455    if repo
456        .is_dirty()
457        .map_err(|e| UpdateError::GitStatus(Box::new(e)))?
458    {
459        return Err(UpdateError::DirtyWorkingTree);
460    }
461
462    // ── 2. Fetch from the default remote ────────────────────────────────────
463    let interrupt = AtomicBool::new(false);
464
465    let remote = repo
466        .find_default_remote(gix::remote::Direction::Fetch)
467        .ok_or_else(|| UpdateError::NoRemote {
468            path: repo_path.to_path_buf(),
469        })?
470        .map_err(|_| UpdateError::NoRemote {
471            path: repo_path.to_path_buf(),
472        })?;
473
474    remote
475        .connect(gix::remote::Direction::Fetch)
476        .map_err(|e| UpdateError::Connect(Box::new(e)))?
477        .prepare_fetch(gix::progress::Discard, Default::default())
478        .map_err(|e| UpdateError::PrepareFetch(Box::new(e)))?
479        .receive(gix::progress::Discard, &interrupt)
480        .map_err(|e| UpdateError::Fetch(Box::new(e)))?;
481
482    // ── 3. Resolve local branch and remote-tracking ref ──────────────────────
483    let head_ref = repo
484        .head_ref()
485        .map_err(|_| UpdateError::DetachedHead)?
486        .ok_or(UpdateError::DetachedHead)?;
487
488    let tracking_name = repo
489        .branch_remote_tracking_ref_name(head_ref.name(), gix::remote::Direction::Fetch)
490        .ok_or_else(|| UpdateError::NoTrackingRef {
491            branch: head_ref.name().shorten().to_string(),
492        })?
493        .map_err(|_| UpdateError::NoTrackingRef {
494            branch: head_ref.name().shorten().to_string(),
495        })?;
496
497    let mut tracking_ref =
498        repo.find_reference(tracking_name.as_ref())
499            .map_err(|_| UpdateError::NoTrackingRef {
500                branch: head_ref.name().shorten().to_string(),
501            })?;
502
503    let new_oid = tracking_ref
504        .peel_to_id()
505        .map_err(UpdateError::PeelRef)?
506        .detach();
507
508    // ── 4. Already up to date? ───────────────────────────────────────────────
509    let head_oid = repo
510        .head_id()
511        .map_err(|_| UpdateError::DetachedHead)?
512        .detach();
513
514    if head_oid == new_oid {
515        return Ok(false);
516    }
517
518    // ── 5. Fast-forward check ────────────────────────────────────────────────
519    //
520    // A fast-forward is safe iff the current HEAD is an ancestor of the new
521    // remote commit, i.e. merge_base(HEAD, new) == HEAD.
522    let base = repo
523        .merge_base(head_oid, new_oid)
524        .map_err(UpdateError::MergeBase)?
525        .detach();
526
527    if base != head_oid {
528        return Err(UpdateError::NotFastForward);
529    }
530
531    // ── 6. Advance the local branch ref ──────────────────────────────────────
532    use gix::refs::{
533        Target,
534        transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog},
535    };
536
537    repo.edit_reference(RefEdit {
538        change: Change::Update {
539            log: LogChange {
540                mode: RefLog::AndReference,
541                force_create_reflog: false,
542                message: "krypt update: fast-forward".into(),
543            },
544            expected: PreviousValue::MustExistAndMatch(Target::Object(head_oid)),
545            new: Target::Object(new_oid),
546        },
547        name: head_ref.name().to_owned(),
548        deref: false,
549    })
550    .map_err(UpdateError::RefEdit)?;
551
552    // ── 7. Update the working tree to match the new commit ───────────────────
553    //
554    // The working tree is guaranteed clean (step 1), so rebuilding the index
555    // from the new tree and checking out is equivalent to `git reset --hard`.
556    // Files removed from the new tree must be explicitly unlinked: we compare
557    // the old and new indices and delete anything that disappeared.
558    let new_commit = repo
559        .find_object(new_oid)
560        .map_err(|_| UpdateError::DetachedHead)?;
561    let new_tree = new_commit
562        .peel_to_tree()
563        .map_err(|_| UpdateError::DetachedHead)?;
564    let new_tree_id = new_tree.id;
565
566    // Build new index from new tree (high-level helper on Repository).
567    let mut new_index = repo
568        .index_from_tree(new_tree_id.as_ref())
569        .map_err(UpdateError::IndexFromTree)?;
570
571    let new_paths: std::collections::HashSet<Vec<u8>> = new_index
572        .entries()
573        .iter()
574        .map(|e| {
575            let p: &[u8] = e.path(&new_index);
576            p.to_vec()
577        })
578        .collect();
579
580    // Load the previous index to discover deleted files.
581    let old_index = repo
582        .index_or_load_from_head()
583        .map_err(|_| UpdateError::DetachedHead)?;
584
585    let workdir = repo.workdir().ok_or(UpdateError::DetachedHead)?;
586
587    for entry in old_index.entries() {
588        let rel: &[u8] = entry.path(&old_index);
589        if !new_paths.contains(rel)
590            && let Ok(rel_str) = std::str::from_utf8(rel)
591        {
592            let _ = std::fs::remove_file(workdir.join(std::path::Path::new(rel_str)));
593        }
594    }
595
596    // Check out the new index into the working directory.
597    let checkout_opts = repo
598        .checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)
599        .map_err(|e| UpdateError::CheckoutOptions(Box::new(e)))?;
600
601    let interrupt2 = AtomicBool::new(false);
602    let files = gix::progress::Discard;
603    let bytes = gix::progress::Discard;
604
605    gix::worktree::state::checkout(
606        &mut new_index,
607        workdir,
608        repo.objects
609            .clone()
610            .into_arc()
611            .map_err(UpdateError::OdbArc)?,
612        &files,
613        &bytes,
614        &interrupt2,
615        checkout_opts,
616    )
617    .map_err(|e| UpdateError::Checkout(Box::new(e)))?;
618
619    new_index
620        .write(Default::default())
621        .map_err(UpdateError::WriteIndex)?;
622
623    Ok(true)
624}
625
626/// Returns a warning string when our binary version is older than `min_version`.
627fn version_warning_if_older(min_version: &str) -> Option<String> {
628    let our_version = env!("CARGO_PKG_VERSION");
629    if version_less_than(our_version, min_version) {
630        Some(format!(
631            "warning: this repo requires krypt >= {min_version}, but you have {our_version}; \
632             please upgrade"
633        ))
634    } else {
635        None
636    }
637}
638
639/// Returns true if `a` is strictly less than `b` using semver-style comparison.
640///
641/// Parsing failures fall through to lexicographic comparison so the binary
642/// never hard-fails on a malformed `krypt_min` value.
643fn version_less_than(a: &str, b: &str) -> bool {
644    match (parse_version(a), parse_version(b)) {
645        (Some(av), Some(bv)) => av < bv,
646        _ => a < b,
647    }
648}
649
650/// Parse a `MAJOR.MINOR.PATCH` string into a comparable tuple.
651fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
652    let mut parts = v.splitn(3, '.');
653    let major = parts.next()?.parse().ok()?;
654    let minor = parts.next()?.parse().ok()?;
655    let patch = parts
656        .next()?
657        .trim_end_matches(|c: char| !c.is_ascii_digit())
658        .parse()
659        .ok()?;
660    Some((major, minor, patch))
661}
662
663// ─── Tests ──────────────────────────────────────────────────────────────────
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668    use crate::runner::{MockNotifier, MockProcessExec, MockPrompter};
669    use std::fs;
670    use tempfile::tempdir;
671
672    // ── gix test helpers ────────────────────────────────────────────────────
673
674    fn test_sig_raw() -> &'static str {
675        // Raw git signature format: "Name <email> seconds tz"
676        "Test <test@test.test> 0 +0000"
677    }
678
679    /// Write a commit directly via gix's high-level `commit_as` API.
680    fn write_commit(repo: &gix::Repository, message: &str, files: &[(&str, &[u8])]) {
681        // Build and write blob objects, then build tree.
682        let mut tree_entries: Vec<gix::objs::tree::Entry> = files
683            .iter()
684            .map(|(name, content)| {
685                let blob_id = repo.write_blob(content).expect("write blob").detach();
686                gix::objs::tree::Entry {
687                    mode: gix::objs::tree::EntryKind::Blob.into(),
688                    filename: (*name).into(),
689                    oid: blob_id,
690                }
691            })
692            .collect();
693        tree_entries.sort_by(|a, b| a.filename.cmp(&b.filename));
694
695        let tree = gix::objs::Tree {
696            entries: tree_entries,
697        };
698        let tree_id = repo.write_object(&tree).expect("write tree").detach();
699
700        let sig = gix::actor::SignatureRef::from_bytes(test_sig_raw().as_bytes())
701            .expect("valid test sig");
702        let parent: Vec<gix::hash::ObjectId> = repo
703            .head_id()
704            .ok()
705            .map(|id| id.detach())
706            .into_iter()
707            .collect();
708
709        // commit_as updates HEAD automatically (deref through symbolic HEAD).
710        repo.commit_as(sig, sig, "HEAD", message, tree_id, parent)
711            .expect("write commit");
712    }
713
714    /// Init a new repo with a single empty commit.
715    fn init_with_commit(dir: &Path) -> gix::Repository {
716        let repo = gix::init(dir).expect("gix::init");
717        write_commit(&repo, "initial", &[]);
718        repo
719    }
720
721    fn make_tool_config(repo_path: &Path, tc_dir: &tempfile::TempDir) -> PathBuf {
722        let tc_path = tc_dir.path().join("krypt").join("config.toml");
723        let cfg = crate::tool_config::ToolConfig {
724            repo: crate::tool_config::RepoConfig {
725                path: repo_path.to_path_buf(),
726                url: None,
727            },
728        };
729        cfg.save(&tc_path).unwrap();
730        tc_path
731    }
732
733    // ── Hook helper tests (no full git setup needed) ─────────────────────────
734
735    fn make_cfg_with_hooks(toml: &str) -> Config {
736        toml::from_str(toml).expect("parse config")
737    }
738
739    // ── 1. No hooks ──────────────────────────────────────────────────────────
740
741    #[test]
742    fn no_hooks_returns_zero_summary() {
743        let cfg = make_cfg_with_hooks("");
744        let notifier = MockNotifier::default();
745        let mut prompter = MockPrompter::default();
746
747        let summary = run_post_update_hooks_with_exec(
748            Some(&cfg),
749            false,
750            false,
751            &MockProcessExec::new([]),
752            &notifier,
753            &mut prompter,
754        )
755        .unwrap();
756
757        assert_eq!(summary.total, 0);
758        assert_eq!(summary.ran, 0);
759        assert_eq!(summary.skipped_by_predicate, 0);
760        assert_eq!(summary.skipped_by_flag, 0);
761        assert_eq!(summary.failed_ignored, 0);
762        assert!(!summary.dry_run);
763    }
764
765    // ── 2. One hook, succeeds ────────────────────────────────────────────────
766
767    #[test]
768    fn one_hook_succeeds() {
769        use crate::runner::ProcessResult;
770
771        let cfg = make_cfg_with_hooks(
772            r#"
773[[hook]]
774name = "my-hook"
775when = "post-update"
776run  = ["echo", "hi"]
777"#,
778        );
779
780        let process = MockProcessExec::new([Ok(ProcessResult {
781            status: 0,
782            stdout: "hi\n".to_owned(),
783            stderr: String::new(),
784        })]);
785        let notifier = MockNotifier::default();
786        let mut prompter = MockPrompter::default();
787
788        let summary = run_post_update_hooks_with_exec(
789            Some(&cfg),
790            false,
791            false,
792            &process,
793            &notifier,
794            &mut prompter,
795        )
796        .unwrap();
797
798        assert_eq!(summary.total, 1);
799        assert_eq!(summary.ran, 1);
800        assert_eq!(summary.skipped_by_predicate, 0);
801        assert_eq!(summary.failed_ignored, 0);
802        // Verify the process was actually called.
803        let calls = process.calls.borrow();
804        assert_eq!(calls[0].0, "echo");
805    }
806
807    // ── 3. Predicate false → skipped_by_predicate ────────────────────────────
808
809    #[test]
810    fn hook_with_false_predicate_skipped() {
811        // Use `env:KRYPT_TEST_IMPOSSIBLE_VAR_NEVER_SET` — an env var that is
812        // guaranteed not to exist on any CI runner, so the predicate is always
813        // false on Linux, macOS, and Windows alike.
814        let cfg = make_cfg_with_hooks(
815            r#"
816[[hook]]
817name  = "impossible-env"
818when  = "post-update"
819if    = "env:KRYPT_TEST_IMPOSSIBLE_VAR_NEVER_SET"
820run   = ["echo", "nope"]
821"#,
822        );
823
824        let process = MockProcessExec::new([]);
825        let notifier = MockNotifier::default();
826        let mut prompter = MockPrompter::default();
827
828        let summary = run_post_update_hooks_with_exec(
829            Some(&cfg),
830            false,
831            false,
832            &process,
833            &notifier,
834            &mut prompter,
835        )
836        .unwrap();
837
838        assert_eq!(summary.total, 1);
839        assert_eq!(summary.ran, 0);
840        assert_eq!(summary.skipped_by_predicate, 1);
841        // Process must never have been called.
842        assert!(process.calls.borrow().is_empty());
843    }
844
845    // ── 4. Hook fails, ignore_failure = true → failed_ignored ────────────────
846
847    #[test]
848    fn hook_fails_ignore_failure_true_continues() {
849        use crate::runner::ProcessResult;
850
851        let cfg = make_cfg_with_hooks(
852            r#"
853[[hook]]
854name           = "lenient"
855when           = "post-update"
856run            = ["false-cmd"]
857ignore_failure = true
858"#,
859        );
860
861        let process = MockProcessExec::new([Ok(ProcessResult {
862            status: 1,
863            stdout: String::new(),
864            stderr: "error".to_owned(),
865        })]);
866        let notifier = MockNotifier::default();
867        let mut prompter = MockPrompter::default();
868
869        let result = run_post_update_hooks_with_exec(
870            Some(&cfg),
871            false,
872            false,
873            &process,
874            &notifier,
875            &mut prompter,
876        );
877
878        let summary = result.expect("should return Ok despite hook failure");
879        assert_eq!(summary.failed_ignored, 1);
880        assert_eq!(summary.ran, 0);
881    }
882
883    // ── 5. Hook fails, ignore_failure = false → Err(UpdateError::Hook) ───────
884
885    #[test]
886    fn hook_fails_ignore_failure_false_returns_err() {
887        use crate::runner::ProcessResult;
888
889        let cfg = make_cfg_with_hooks(
890            r#"
891[[hook]]
892name = "strict"
893when = "post-update"
894run  = ["bad-cmd"]
895"#,
896        );
897
898        let process = MockProcessExec::new([Ok(ProcessResult {
899            status: 1,
900            stdout: String::new(),
901            stderr: "boom".to_owned(),
902        })]);
903        let notifier = MockNotifier::default();
904        let mut prompter = MockPrompter::default();
905
906        let err = run_post_update_hooks_with_exec(
907            Some(&cfg),
908            false,
909            false,
910            &process,
911            &notifier,
912            &mut prompter,
913        )
914        .unwrap_err();
915
916        assert!(
917            matches!(&err, UpdateError::Hook { name, .. } if name == "strict"),
918            "expected UpdateError::Hook {{ name: \"strict\", .. }}, got {err:?}"
919        );
920    }
921
922    // ── 6. --skip-hooks → skipped_by_flag == total ───────────────────────────
923
924    #[test]
925    fn skip_hooks_flag_skips_all() {
926        let cfg = make_cfg_with_hooks(
927            r#"
928[[hook]]
929name = "h1"
930when = "post-update"
931run  = ["echo", "one"]
932
933[[hook]]
934name = "h2"
935when = "post-update"
936run  = ["echo", "two"]
937"#,
938        );
939
940        let process = MockProcessExec::new([]);
941        let notifier = MockNotifier::default();
942        let mut prompter = MockPrompter::default();
943
944        let summary = run_post_update_hooks_with_exec(
945            Some(&cfg),
946            true, // skip = true
947            false,
948            &process,
949            &notifier,
950            &mut prompter,
951        )
952        .unwrap();
953
954        assert_eq!(summary.total, 2);
955        assert_eq!(summary.skipped_by_flag, 2);
956        assert_eq!(summary.ran, 0);
957        // Process must never have been called.
958        assert!(process.calls.borrow().is_empty());
959    }
960
961    // ── 7. --dry-run → HookSummary.dry_run = true, counters = 0 ─────────────
962
963    #[test]
964    fn dry_run_sets_flag_no_execution() {
965        let cfg = make_cfg_with_hooks(
966            r#"
967[[hook]]
968name = "deploy"
969when = "post-update"
970run  = ["echo", "deploying"]
971"#,
972        );
973
974        let process = MockProcessExec::new([]);
975        let notifier = MockNotifier::default();
976        let mut prompter = MockPrompter::default();
977
978        let summary = run_post_update_hooks_with_exec(
979            Some(&cfg),
980            false,
981            true, // dry_run = true
982            &process,
983            &notifier,
984            &mut prompter,
985        )
986        .unwrap();
987
988        assert!(summary.dry_run);
989        assert_eq!(summary.ran, 0);
990        assert_eq!(summary.skipped_by_predicate, 0);
991        assert_eq!(summary.skipped_by_flag, 0);
992        assert_eq!(summary.failed_ignored, 0);
993        // No process spawned.
994        assert!(process.calls.borrow().is_empty());
995    }
996
997    // ── Tests ────────────────────────────────────────────────────────────────
998
999    /// A modified index entry (tree-vs-index mismatch) causes `DirtyWorkingTree`.
1000    ///
1001    /// gix's `is_dirty()` does not flag *untracked* files (matching git's
1002    /// `--ignore-untracked` semantics).  For a dotfiles repo this is correct:
1003    /// a stray untracked file in the repo root should not block a pull.
1004    ///
1005    /// We trigger a tree-vs-index mismatch by staging a blob that is different
1006    /// from what the HEAD commit contains.
1007    #[test]
1008    fn dirty_tree_always_errors() {
1009        let local = tempdir().unwrap();
1010
1011        // Commit a tracked file.
1012        write_commit(
1013            &init_with_commit(local.path()),
1014            "add file",
1015            &[("tracked.txt", b"original")],
1016        );
1017
1018        // Make the index dirty: write the file with different content to disk
1019        // AND update the index to point to a blob with different content than
1020        // the HEAD tree has.  We do this by staging via gix's index APIs.
1021        //
1022        // The simplest approach: after commit, the index (if it exists on disk)
1023        // should match HEAD.  We rebuild it from the current HEAD tree, then
1024        // write different content to disk so that the index SHA != worktree SHA.
1025        {
1026            let repo = gix::open(local.path()).expect("open");
1027            let head_tree_id = repo
1028                .head_commit()
1029                .expect("head commit")
1030                .tree_id()
1031                .expect("tree");
1032            let mut idx = repo
1033                .index_from_tree(head_tree_id.as_ref())
1034                .expect("index from tree");
1035            // Write the index to disk so gix can compare it with the worktree.
1036            idx.write(Default::default()).expect("write index");
1037        }
1038        // Now modify the file on disk so it differs from what the index records.
1039        fs::write(local.path().join("tracked.txt"), b"modified").unwrap();
1040
1041        let tc_dir = tempdir().unwrap();
1042        let tc_path = make_tool_config(local.path(), &tc_dir);
1043        let state = tempdir().unwrap();
1044
1045        let err = update(&UpdateOpts {
1046            tool_config_path: tc_path,
1047            config_path: Some(local.path().join(".krypt.toml")),
1048            manifest_path: state.path().join("manifest.json"),
1049            dry_run: false,
1050            skip_hooks: false,
1051            force: false,
1052        })
1053        .unwrap_err();
1054
1055        assert!(
1056            matches!(err, UpdateError::DirtyWorkingTree),
1057            "expected DirtyWorkingTree, got {err:?}"
1058        );
1059    }
1060
1061    #[test]
1062    fn tool_config_missing_gives_clear_error() {
1063        let tc_dir = tempdir().unwrap();
1064        let tc_path = tc_dir.path().join("nonexistent.toml");
1065        let state = tempdir().unwrap();
1066
1067        let err = update(&UpdateOpts {
1068            tool_config_path: tc_path.clone(),
1069            config_path: None,
1070            manifest_path: state.path().join("manifest.json"),
1071            dry_run: false,
1072            skip_hooks: false,
1073            force: false,
1074        })
1075        .unwrap_err();
1076
1077        assert!(
1078            matches!(err, UpdateError::ToolConfigMissing { ref path } if path == &tc_path),
1079            "expected ToolConfigMissing, got {err:?}"
1080        );
1081    }
1082
1083    #[test]
1084    fn version_warning_fires_when_older() {
1085        assert!(version_less_than("0.0.2", "99.0.0"));
1086        let warn = version_warning_if_older("99.0.0");
1087        assert!(warn.is_some());
1088        assert!(warn.unwrap().contains("99.0.0"));
1089    }
1090
1091    #[test]
1092    fn version_warning_absent_when_current() {
1093        let our = env!("CARGO_PKG_VERSION");
1094        assert!(version_warning_if_older(our).is_none());
1095    }
1096
1097    #[test]
1098    fn parse_version_basic() {
1099        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
1100        assert_eq!(parse_version("0.0.0"), Some((0, 0, 0)));
1101        assert!(parse_version("bad").is_none());
1102    }
1103}