1#![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#[derive(Debug, Error)]
38pub enum UpdateError {
39 #[error("tool config not found at {path:?} — run `krypt init` first")]
41 ToolConfigMissing {
42 path: PathBuf,
44 },
45
46 #[error("loading tool config: {0}")]
48 ToolConfig(#[from] ToolConfigError),
49
50 #[error(
58 "working tree has uncommitted changes — commit, stash, or discard them \
59 and re-run `krypt update`"
60 )]
61 DirtyWorkingTree,
62
63 #[error("opening git repo at {path:?}: {source}")]
65 OpenRepo {
66 path: PathBuf,
68 #[source]
70 source: Box<gix::open::Error>,
71 },
72
73 #[error("checking git status: {0}")]
75 GitStatus(#[source] Box<gix::status::is_dirty::Error>),
76
77 #[error("no default fetch remote configured in {path:?}")]
79 NoRemote {
80 path: PathBuf,
82 },
83
84 #[error("connecting to remote: {0}")]
86 Connect(#[source] Box<gix::remote::connect::Error>),
87
88 #[error("preparing fetch: {0}")]
90 PrepareFetch(#[source] Box<gix::remote::fetch::prepare::Error>),
91
92 #[error("fetching from remote: {0}")]
94 Fetch(#[source] Box<gix::remote::fetch::Error>),
95
96 #[error("HEAD is detached or could not be resolved — cannot fast-forward")]
98 DetachedHead,
99
100 #[error("no remote-tracking ref for branch {branch:?}")]
102 NoTrackingRef {
103 branch: String,
105 },
106
107 #[error("merge-base computation: {0}")]
109 MergeBase(#[source] gix::repository::merge_base::Error),
110
111 #[error("remote is not a fast-forward of local HEAD — cannot pull without merging")]
113 NotFastForward,
114
115 #[error("advancing local branch ref: {0}")]
117 RefEdit(#[source] gix::reference::edit::Error),
118
119 #[error("rebuilding index from new commit tree: {0}")]
121 IndexFromTree(#[source] gix::repository::index_from_tree::Error),
122
123 #[error("checking out new working tree: {0}")]
125 Checkout(#[source] Box<gix::worktree::state::checkout::Error>),
126
127 #[error("writing index: {0}")]
129 WriteIndex(#[source] gix::index::file::write::Error),
130
131 #[error("checkout options: {0}")]
133 CheckoutOptions(#[source] Box<gix::config::checkout_options::Error>),
134
135 #[error("converting object store to Arc: {0}")]
137 OdbArc(#[source] std::io::Error),
138
139 #[error("looking up ref OID: {0}")]
141 PeelRef(#[source] gix::reference::peel::Error),
142
143 #[error("deploy link: {0}")]
145 Deploy(#[from] DeployError),
146
147 #[error("hook {name:?} failed: {source}")]
151 Hook {
152 name: String,
154 #[source]
156 source: Box<RunnerError>,
157 },
158}
159
160pub struct UpdateOpts {
169 pub tool_config_path: PathBuf,
171
172 pub config_path: Option<PathBuf>,
174
175 pub manifest_path: PathBuf,
177
178 pub dry_run: bool,
180
181 pub skip_hooks: bool,
183
184 pub force: bool,
186}
187
188#[derive(Debug, Default)]
190pub struct HookSummary {
191 pub total: usize,
193 pub ran: usize,
195 pub skipped_by_predicate: usize,
197 pub skipped_by_flag: usize,
199 pub failed_ignored: usize,
201 pub dry_run: bool,
203}
204
205#[derive(Debug)]
207pub struct UpdateReport {
208 pub pulled: bool,
210
211 pub link: LinkReport,
213
214 pub version_warning: Option<String>,
216
217 pub hooks: HookSummary,
219}
220
221pub 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 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 ¬ifier,
269 &mut prompter,
270 )?;
271
272 Ok(UpdateReport {
273 pulled,
274 link: link_report,
275 version_warning,
276 hooks: hooks_summary,
277 })
278}
279
280pub(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
308pub(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 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 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 println!("hooks (dry-run):");
354
355 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 return Ok(summary);
379 }
380
381 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 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 match execute_hook(hook, process, notifier, prompter, &eval_predicate) {
401 Ok(report) if report.steps_failed_ignored > 0 => {
402 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 return Err(UpdateError::Hook {
415 name: hook.name.clone(),
416 source: Box::new(e),
417 });
418 }
419 }
420 }
421
422 Ok(summary)
423}
424
425fn 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 if repo
456 .is_dirty()
457 .map_err(|e| UpdateError::GitStatus(Box::new(e)))?
458 {
459 return Err(UpdateError::DirtyWorkingTree);
460 }
461
462 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 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 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 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 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 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 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 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 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
626fn 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
639fn 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
650fn 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#[cfg(test)]
666mod tests {
667 use super::*;
668 use crate::runner::{MockNotifier, MockProcessExec, MockPrompter};
669 use std::fs;
670 use tempfile::tempdir;
671
672 fn test_sig_raw() -> &'static str {
675 "Test <test@test.test> 0 +0000"
677 }
678
679 fn write_commit(repo: &gix::Repository, message: &str, files: &[(&str, &[u8])]) {
681 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 repo.commit_as(sig, sig, "HEAD", message, tree_id, parent)
711 .expect("write commit");
712 }
713
714 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 fn make_cfg_with_hooks(toml: &str) -> Config {
736 toml::from_str(toml).expect("parse config")
737 }
738
739 #[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 ¬ifier,
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 #[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 ¬ifier,
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 let calls = process.calls.borrow();
804 assert_eq!(calls[0].0, "echo");
805 }
806
807 #[test]
810 fn hook_with_false_predicate_skipped() {
811 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 ¬ifier,
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 assert!(process.calls.borrow().is_empty());
843 }
844
845 #[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 ¬ifier,
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 #[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 ¬ifier,
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 #[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, false,
948 &process,
949 ¬ifier,
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 assert!(process.calls.borrow().is_empty());
959 }
960
961 #[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, &process,
983 ¬ifier,
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 assert!(process.calls.borrow().is_empty());
995 }
996
997 #[test]
1008 fn dirty_tree_always_errors() {
1009 let local = tempdir().unwrap();
1010
1011 write_commit(
1013 &init_with_commit(local.path()),
1014 "add file",
1015 &[("tracked.txt", b"original")],
1016 );
1017
1018 {
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 idx.write(Default::default()).expect("write index");
1037 }
1038 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}