1#![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#[derive(Debug, Error)]
35pub enum UpdateError {
36 #[error("tool config not found at {path:?} — run `krypt init` first")]
38 ToolConfigMissing {
39 path: PathBuf,
41 },
42
43 #[error("loading tool config: {0}")]
45 ToolConfig(#[from] ToolConfigError),
46
47 #[error(
55 "working tree has uncommitted changes — commit, stash, or discard them \
56 and re-run `krypt update`"
57 )]
58 DirtyWorkingTree,
59
60 #[error("opening git repo at {path:?}: {source}")]
62 OpenRepo {
63 path: PathBuf,
65 #[source]
67 source: Box<gix::open::Error>,
68 },
69
70 #[error("checking git status: {0}")]
72 GitStatus(#[source] Box<gix::status::is_dirty::Error>),
73
74 #[error("no default fetch remote configured in {path:?}")]
76 NoRemote {
77 path: PathBuf,
79 },
80
81 #[error("connecting to remote: {0}")]
83 Connect(#[source] Box<gix::remote::connect::Error>),
84
85 #[error("preparing fetch: {0}")]
87 PrepareFetch(#[source] Box<gix::remote::fetch::prepare::Error>),
88
89 #[error("fetching from remote: {0}")]
91 Fetch(#[source] Box<gix::remote::fetch::Error>),
92
93 #[error("HEAD is detached or could not be resolved — cannot fast-forward")]
95 DetachedHead,
96
97 #[error("no remote-tracking ref for branch {branch:?}")]
99 NoTrackingRef {
100 branch: String,
102 },
103
104 #[error("merge-base computation: {0}")]
106 MergeBase(#[source] gix::repository::merge_base::Error),
107
108 #[error("remote is not a fast-forward of local HEAD — cannot pull without merging")]
110 NotFastForward,
111
112 #[error("advancing local branch ref: {0}")]
114 RefEdit(#[source] gix::reference::edit::Error),
115
116 #[error("rebuilding index from new commit tree: {0}")]
118 IndexFromTree(#[source] gix::repository::index_from_tree::Error),
119
120 #[error("checking out new working tree: {0}")]
122 Checkout(#[source] Box<gix::worktree::state::checkout::Error>),
123
124 #[error("writing index: {0}")]
126 WriteIndex(#[source] gix::index::file::write::Error),
127
128 #[error("checkout options: {0}")]
130 CheckoutOptions(#[source] Box<gix::config::checkout_options::Error>),
131
132 #[error("converting object store to Arc: {0}")]
134 OdbArc(#[source] std::io::Error),
135
136 #[error("looking up ref OID: {0}")]
138 PeelRef(#[source] gix::reference::peel::Error),
139
140 #[error("deploy link: {0}")]
142 Deploy(#[from] DeployError),
143}
144
145pub struct UpdateOpts {
154 pub tool_config_path: PathBuf,
156
157 pub config_path: Option<PathBuf>,
159
160 pub manifest_path: PathBuf,
162
163 pub dry_run: bool,
165
166 pub skip_hooks: bool,
168
169 pub force: bool,
171}
172
173#[derive(Debug)]
175pub struct UpdateReport {
176 pub pulled: bool,
178
179 pub link: LinkReport,
181
182 pub version_warning: Option<String>,
184
185 pub hooks_skipped: usize,
187}
188
189pub 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
238fn 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 if repo
269 .is_dirty()
270 .map_err(|e| UpdateError::GitStatus(Box::new(e)))?
271 {
272 return Err(UpdateError::DirtyWorkingTree);
273 }
274
275 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 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 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 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 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 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 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 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 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
439fn 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
452fn 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
463fn 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#[cfg(test)]
479mod tests {
480 use super::*;
481 use std::fs;
482 use tempfile::tempdir;
483
484 fn test_sig_raw() -> &'static str {
487 "Test <test@test.test> 0 +0000"
489 }
490
491 fn write_commit(repo: &gix::Repository, message: &str, files: &[(&str, &[u8])]) {
493 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 repo.commit_as(sig, sig, "HEAD", message, tree_id, parent)
523 .expect("write commit");
524 }
525
526 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 #[test]
556 fn dirty_tree_always_errors() {
557 let local = tempdir().unwrap();
558
559 write_commit(
561 &init_with_commit(local.path()),
562 "add file",
563 &[("tracked.txt", b"original")],
564 );
565
566 {
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 idx.write(Default::default()).expect("write index");
585 }
586 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}