#![allow(clippy::result_large_err)]
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use thiserror::Error;
use crate::config::Config;
use crate::deploy::{DeployError, DeployOpts, LinkReport, link};
use crate::predicate::{DefaultPredicateEnv, default_predicate_evaluator, eval};
use crate::runner::{Context, Notifier, ProcessExec, Prompter, RunnerError, execute_hook};
use crate::tool_config::{ToolConfig, ToolConfigError};
#[derive(Debug, Error)]
pub enum UpdateError {
#[error("tool config not found at {path:?} — run `krypt init` first")]
ToolConfigMissing {
path: PathBuf,
},
#[error("loading tool config: {0}")]
ToolConfig(#[from] ToolConfigError),
#[error(
"working tree has uncommitted changes — commit, stash, or discard them \
and re-run `krypt update`"
)]
DirtyWorkingTree,
#[error("opening git repo at {path:?}: {source}")]
OpenRepo {
path: PathBuf,
#[source]
source: Box<gix::open::Error>,
},
#[error("checking git status: {0}")]
GitStatus(#[source] Box<gix::status::is_dirty::Error>),
#[error("no default fetch remote configured in {path:?}")]
NoRemote {
path: PathBuf,
},
#[error("connecting to remote: {0}")]
Connect(#[source] Box<gix::remote::connect::Error>),
#[error("preparing fetch: {0}")]
PrepareFetch(#[source] Box<gix::remote::fetch::prepare::Error>),
#[error("fetching from remote: {0}")]
Fetch(#[source] Box<gix::remote::fetch::Error>),
#[error("HEAD is detached or could not be resolved — cannot fast-forward")]
DetachedHead,
#[error("no remote-tracking ref for branch {branch:?}")]
NoTrackingRef {
branch: String,
},
#[error("merge-base computation: {0}")]
MergeBase(#[source] gix::repository::merge_base::Error),
#[error("remote is not a fast-forward of local HEAD — cannot pull without merging")]
NotFastForward,
#[error("advancing local branch ref: {0}")]
RefEdit(#[source] gix::reference::edit::Error),
#[error("rebuilding index from new commit tree: {0}")]
IndexFromTree(#[source] gix::repository::index_from_tree::Error),
#[error("checking out new working tree: {0}")]
Checkout(#[source] Box<gix::worktree::state::checkout::Error>),
#[error("writing index: {0}")]
WriteIndex(#[source] gix::index::file::write::Error),
#[error("checkout options: {0}")]
CheckoutOptions(#[source] Box<gix::config::checkout_options::Error>),
#[error("converting object store to Arc: {0}")]
OdbArc(#[source] std::io::Error),
#[error("looking up ref OID: {0}")]
PeelRef(#[source] gix::reference::peel::Error),
#[error("deploy link: {0}")]
Deploy(#[from] DeployError),
#[error("hook {name:?} failed: {source}")]
Hook {
name: String,
#[source]
source: Box<RunnerError>,
},
}
pub struct UpdateOpts {
pub tool_config_path: PathBuf,
pub config_path: Option<PathBuf>,
pub manifest_path: PathBuf,
pub dry_run: bool,
pub skip_hooks: bool,
pub force: bool,
}
#[derive(Debug, Default)]
pub struct HookSummary {
pub total: usize,
pub ran: usize,
pub skipped_by_predicate: usize,
pub skipped_by_flag: usize,
pub failed_ignored: usize,
pub dry_run: bool,
}
#[derive(Debug)]
pub struct UpdateReport {
pub pulled: bool,
pub link: LinkReport,
pub version_warning: Option<String>,
pub hooks: HookSummary,
}
pub fn update(opts: &UpdateOpts) -> Result<UpdateReport, UpdateError> {
let tool_cfg = ToolConfig::load(&opts.tool_config_path)?.ok_or_else(|| {
UpdateError::ToolConfigMissing {
path: opts.tool_config_path.clone(),
}
})?;
let repo_path = &tool_cfg.repo.path;
let config_path = opts
.config_path
.clone()
.unwrap_or_else(|| repo_path.join(".krypt.toml"));
let pulled = gix_ff_pull(repo_path)?;
let krypt_cfg = crate::include::load_with_includes(&config_path).ok();
let version_warning = krypt_cfg
.as_ref()
.and_then(|c| c.meta.krypt_min.as_deref())
.and_then(version_warning_if_older);
let link_report = link(&DeployOpts {
config_path,
manifest_path: opts.manifest_path.clone(),
platform: None,
dry_run: opts.dry_run,
force: opts.force,
})?;
let notifier = crate::notify::AutoNotifier::new(
krypt_cfg
.as_ref()
.and_then(|c| c.meta.notify_backend.as_deref()),
);
let mut prompter = crate::runner::RealPrompter;
let hooks_summary = run_post_update_hooks_inner(
krypt_cfg.as_ref(),
opts.skip_hooks,
opts.dry_run,
¬ifier,
&mut prompter,
)?;
Ok(UpdateReport {
pulled,
link: link_report,
version_warning,
hooks: hooks_summary,
})
}
pub(crate) fn run_post_update_hooks_inner(
cfg: Option<&Config>,
skip: bool,
dry_run: bool,
notifier: &dyn Notifier,
prompter: &mut dyn Prompter,
) -> Result<HookSummary, UpdateError> {
run_post_update_hooks_with_exec(
cfg,
skip,
dry_run,
&crate::runner::RealProcessExec,
notifier,
prompter,
)
}
pub(crate) fn run_post_update_hooks_with_exec(
cfg: Option<&Config>,
skip: bool,
dry_run: bool,
process: &dyn ProcessExec,
notifier: &dyn Notifier,
prompter: &mut dyn Prompter,
) -> Result<HookSummary, UpdateError> {
let Some(cfg) = cfg else {
return Ok(HookSummary::default());
};
let post_update_hooks: Vec<_> = cfg
.hooks
.iter()
.filter(|h| h.when == "post-update")
.collect();
let total = post_update_hooks.len();
let mut summary = HookSummary {
total,
dry_run,
..Default::default()
};
if total == 0 {
return Ok(summary);
}
let mut resolver = crate::paths::Resolver::new();
resolver = resolver.with_overrides(cfg.paths.clone().into_iter().collect());
let env = DefaultPredicateEnv::with_resolver(resolver);
let eval_predicate = default_predicate_evaluator(env);
if skip {
summary.skipped_by_flag = total;
return Ok(summary);
}
if dry_run {
println!("hooks (dry-run):");
let mut resolver2 = crate::paths::Resolver::new();
resolver2 = resolver2.with_overrides(cfg.paths.clone().into_iter().collect());
let env2 = DefaultPredicateEnv::with_resolver(resolver2);
for hook in &post_update_hooks {
let predicate_result = if let Some(ref pred) = hook.r#if {
match eval(pred, &env2) {
Ok(true) => "ok",
Ok(false) => "would-skip",
Err(_) => "predicate-error",
}
} else {
"ok"
};
let run_preview = hook.run.first().map(String::as_str).unwrap_or("<empty>");
println!(
" hook {:?}: {} — {}",
hook.name, predicate_result, run_preview
);
}
return Ok(summary);
}
let ctx = Context {
captures: std::collections::BTreeMap::new(),
args: Vec::new(),
stdin: None,
};
for hook in &post_update_hooks {
if hook
.r#if
.as_deref()
.is_some_and(|pred| !eval_predicate(pred, &ctx))
{
summary.skipped_by_predicate += 1;
continue;
}
match execute_hook(hook, process, notifier, prompter, &eval_predicate) {
Ok(report) if report.steps_failed_ignored > 0 => {
tracing::warn!(
hook = %hook.name,
"post-update hook failed (ignore_failure = true) — continuing"
);
summary.failed_ignored += 1;
}
Ok(_) => {
summary.ran += 1;
}
Err(e) => {
return Err(UpdateError::Hook {
name: hook.name.clone(),
source: Box::new(e),
});
}
}
}
Ok(summary)
}
fn gix_ff_pull(repo_path: &Path) -> Result<bool, UpdateError> {
let repo = gix::open(repo_path).map_err(|e| UpdateError::OpenRepo {
path: repo_path.to_path_buf(),
source: Box::new(e),
})?;
if repo
.is_dirty()
.map_err(|e| UpdateError::GitStatus(Box::new(e)))?
{
return Err(UpdateError::DirtyWorkingTree);
}
let interrupt = AtomicBool::new(false);
let remote = repo
.find_default_remote(gix::remote::Direction::Fetch)
.ok_or_else(|| UpdateError::NoRemote {
path: repo_path.to_path_buf(),
})?
.map_err(|_| UpdateError::NoRemote {
path: repo_path.to_path_buf(),
})?;
remote
.connect(gix::remote::Direction::Fetch)
.map_err(|e| UpdateError::Connect(Box::new(e)))?
.prepare_fetch(gix::progress::Discard, Default::default())
.map_err(|e| UpdateError::PrepareFetch(Box::new(e)))?
.receive(gix::progress::Discard, &interrupt)
.map_err(|e| UpdateError::Fetch(Box::new(e)))?;
let head_ref = repo
.head_ref()
.map_err(|_| UpdateError::DetachedHead)?
.ok_or(UpdateError::DetachedHead)?;
let tracking_name = repo
.branch_remote_tracking_ref_name(head_ref.name(), gix::remote::Direction::Fetch)
.ok_or_else(|| UpdateError::NoTrackingRef {
branch: head_ref.name().shorten().to_string(),
})?
.map_err(|_| UpdateError::NoTrackingRef {
branch: head_ref.name().shorten().to_string(),
})?;
let mut tracking_ref =
repo.find_reference(tracking_name.as_ref())
.map_err(|_| UpdateError::NoTrackingRef {
branch: head_ref.name().shorten().to_string(),
})?;
let new_oid = tracking_ref
.peel_to_id()
.map_err(UpdateError::PeelRef)?
.detach();
let head_oid = repo
.head_id()
.map_err(|_| UpdateError::DetachedHead)?
.detach();
if head_oid == new_oid {
return Ok(false);
}
let base = repo
.merge_base(head_oid, new_oid)
.map_err(UpdateError::MergeBase)?
.detach();
if base != head_oid {
return Err(UpdateError::NotFastForward);
}
use gix::refs::{
Target,
transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog},
};
repo.edit_reference(RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "krypt update: fast-forward".into(),
},
expected: PreviousValue::MustExistAndMatch(Target::Object(head_oid)),
new: Target::Object(new_oid),
},
name: head_ref.name().to_owned(),
deref: false,
})
.map_err(UpdateError::RefEdit)?;
let new_commit = repo
.find_object(new_oid)
.map_err(|_| UpdateError::DetachedHead)?;
let new_tree = new_commit
.peel_to_tree()
.map_err(|_| UpdateError::DetachedHead)?;
let new_tree_id = new_tree.id;
let mut new_index = repo
.index_from_tree(new_tree_id.as_ref())
.map_err(UpdateError::IndexFromTree)?;
let new_paths: std::collections::HashSet<Vec<u8>> = new_index
.entries()
.iter()
.map(|e| {
let p: &[u8] = e.path(&new_index);
p.to_vec()
})
.collect();
let old_index = repo
.index_or_load_from_head()
.map_err(|_| UpdateError::DetachedHead)?;
let workdir = repo.workdir().ok_or(UpdateError::DetachedHead)?;
for entry in old_index.entries() {
let rel: &[u8] = entry.path(&old_index);
if !new_paths.contains(rel)
&& let Ok(rel_str) = std::str::from_utf8(rel)
{
let _ = std::fs::remove_file(workdir.join(std::path::Path::new(rel_str)));
}
}
let checkout_opts = repo
.checkout_options(gix::worktree::stack::state::attributes::Source::IdMapping)
.map_err(|e| UpdateError::CheckoutOptions(Box::new(e)))?;
let interrupt2 = AtomicBool::new(false);
let files = gix::progress::Discard;
let bytes = gix::progress::Discard;
gix::worktree::state::checkout(
&mut new_index,
workdir,
repo.objects
.clone()
.into_arc()
.map_err(UpdateError::OdbArc)?,
&files,
&bytes,
&interrupt2,
checkout_opts,
)
.map_err(|e| UpdateError::Checkout(Box::new(e)))?;
new_index
.write(Default::default())
.map_err(UpdateError::WriteIndex)?;
Ok(true)
}
fn version_warning_if_older(min_version: &str) -> Option<String> {
let our_version = env!("CARGO_PKG_VERSION");
if version_less_than(our_version, min_version) {
Some(format!(
"warning: this repo requires krypt >= {min_version}, but you have {our_version}; \
please upgrade"
))
} else {
None
}
}
fn version_less_than(a: &str, b: &str) -> bool {
match (parse_version(a), parse_version(b)) {
(Some(av), Some(bv)) => av < bv,
_ => a < b,
}
}
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts
.next()?
.trim_end_matches(|c: char| !c.is_ascii_digit())
.parse()
.ok()?;
Some((major, minor, patch))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runner::{MockNotifier, MockProcessExec, MockPrompter};
use std::fs;
use tempfile::tempdir;
fn test_sig_raw() -> &'static str {
"Test <test@test.test> 0 +0000"
}
fn write_commit(repo: &gix::Repository, message: &str, files: &[(&str, &[u8])]) {
let mut tree_entries: Vec<gix::objs::tree::Entry> = files
.iter()
.map(|(name, content)| {
let blob_id = repo.write_blob(content).expect("write blob").detach();
gix::objs::tree::Entry {
mode: gix::objs::tree::EntryKind::Blob.into(),
filename: (*name).into(),
oid: blob_id,
}
})
.collect();
tree_entries.sort_by(|a, b| a.filename.cmp(&b.filename));
let tree = gix::objs::Tree {
entries: tree_entries,
};
let tree_id = repo.write_object(&tree).expect("write tree").detach();
let sig = gix::actor::SignatureRef::from_bytes(test_sig_raw().as_bytes())
.expect("valid test sig");
let parent: Vec<gix::hash::ObjectId> = repo
.head_id()
.ok()
.map(|id| id.detach())
.into_iter()
.collect();
repo.commit_as(sig, sig, "HEAD", message, tree_id, parent)
.expect("write commit");
}
fn init_with_commit(dir: &Path) -> gix::Repository {
let repo = gix::init(dir).expect("gix::init");
write_commit(&repo, "initial", &[]);
repo
}
fn make_tool_config(repo_path: &Path, tc_dir: &tempfile::TempDir) -> PathBuf {
let tc_path = tc_dir.path().join("krypt").join("config.toml");
let cfg = crate::tool_config::ToolConfig {
repo: crate::tool_config::RepoConfig {
path: repo_path.to_path_buf(),
url: None,
},
};
cfg.save(&tc_path).unwrap();
tc_path
}
fn make_cfg_with_hooks(toml: &str) -> Config {
toml::from_str(toml).expect("parse config")
}
#[test]
fn no_hooks_returns_zero_summary() {
let cfg = make_cfg_with_hooks("");
let notifier = MockNotifier::default();
let mut prompter = MockPrompter::default();
let summary = run_post_update_hooks_with_exec(
Some(&cfg),
false,
false,
&MockProcessExec::new([]),
¬ifier,
&mut prompter,
)
.unwrap();
assert_eq!(summary.total, 0);
assert_eq!(summary.ran, 0);
assert_eq!(summary.skipped_by_predicate, 0);
assert_eq!(summary.skipped_by_flag, 0);
assert_eq!(summary.failed_ignored, 0);
assert!(!summary.dry_run);
}
#[test]
fn one_hook_succeeds() {
use crate::runner::ProcessResult;
let cfg = make_cfg_with_hooks(
r#"
[[hook]]
name = "my-hook"
when = "post-update"
run = ["echo", "hi"]
"#,
);
let process = MockProcessExec::new([Ok(ProcessResult {
status: 0,
stdout: "hi\n".to_owned(),
stderr: String::new(),
})]);
let notifier = MockNotifier::default();
let mut prompter = MockPrompter::default();
let summary = run_post_update_hooks_with_exec(
Some(&cfg),
false,
false,
&process,
¬ifier,
&mut prompter,
)
.unwrap();
assert_eq!(summary.total, 1);
assert_eq!(summary.ran, 1);
assert_eq!(summary.skipped_by_predicate, 0);
assert_eq!(summary.failed_ignored, 0);
let calls = process.calls.borrow();
assert_eq!(calls[0].0, "echo");
}
#[test]
fn hook_with_false_predicate_skipped() {
let cfg = make_cfg_with_hooks(
r#"
[[hook]]
name = "impossible-env"
when = "post-update"
if = "env:KRYPT_TEST_IMPOSSIBLE_VAR_NEVER_SET"
run = ["echo", "nope"]
"#,
);
let process = MockProcessExec::new([]);
let notifier = MockNotifier::default();
let mut prompter = MockPrompter::default();
let summary = run_post_update_hooks_with_exec(
Some(&cfg),
false,
false,
&process,
¬ifier,
&mut prompter,
)
.unwrap();
assert_eq!(summary.total, 1);
assert_eq!(summary.ran, 0);
assert_eq!(summary.skipped_by_predicate, 1);
assert!(process.calls.borrow().is_empty());
}
#[test]
fn hook_fails_ignore_failure_true_continues() {
use crate::runner::ProcessResult;
let cfg = make_cfg_with_hooks(
r#"
[[hook]]
name = "lenient"
when = "post-update"
run = ["false-cmd"]
ignore_failure = true
"#,
);
let process = MockProcessExec::new([Ok(ProcessResult {
status: 1,
stdout: String::new(),
stderr: "error".to_owned(),
})]);
let notifier = MockNotifier::default();
let mut prompter = MockPrompter::default();
let result = run_post_update_hooks_with_exec(
Some(&cfg),
false,
false,
&process,
¬ifier,
&mut prompter,
);
let summary = result.expect("should return Ok despite hook failure");
assert_eq!(summary.failed_ignored, 1);
assert_eq!(summary.ran, 0);
}
#[test]
fn hook_fails_ignore_failure_false_returns_err() {
use crate::runner::ProcessResult;
let cfg = make_cfg_with_hooks(
r#"
[[hook]]
name = "strict"
when = "post-update"
run = ["bad-cmd"]
"#,
);
let process = MockProcessExec::new([Ok(ProcessResult {
status: 1,
stdout: String::new(),
stderr: "boom".to_owned(),
})]);
let notifier = MockNotifier::default();
let mut prompter = MockPrompter::default();
let err = run_post_update_hooks_with_exec(
Some(&cfg),
false,
false,
&process,
¬ifier,
&mut prompter,
)
.unwrap_err();
assert!(
matches!(&err, UpdateError::Hook { name, .. } if name == "strict"),
"expected UpdateError::Hook {{ name: \"strict\", .. }}, got {err:?}"
);
}
#[test]
fn skip_hooks_flag_skips_all() {
let cfg = make_cfg_with_hooks(
r#"
[[hook]]
name = "h1"
when = "post-update"
run = ["echo", "one"]
[[hook]]
name = "h2"
when = "post-update"
run = ["echo", "two"]
"#,
);
let process = MockProcessExec::new([]);
let notifier = MockNotifier::default();
let mut prompter = MockPrompter::default();
let summary = run_post_update_hooks_with_exec(
Some(&cfg),
true, false,
&process,
¬ifier,
&mut prompter,
)
.unwrap();
assert_eq!(summary.total, 2);
assert_eq!(summary.skipped_by_flag, 2);
assert_eq!(summary.ran, 0);
assert!(process.calls.borrow().is_empty());
}
#[test]
fn dry_run_sets_flag_no_execution() {
let cfg = make_cfg_with_hooks(
r#"
[[hook]]
name = "deploy"
when = "post-update"
run = ["echo", "deploying"]
"#,
);
let process = MockProcessExec::new([]);
let notifier = MockNotifier::default();
let mut prompter = MockPrompter::default();
let summary = run_post_update_hooks_with_exec(
Some(&cfg),
false,
true, &process,
¬ifier,
&mut prompter,
)
.unwrap();
assert!(summary.dry_run);
assert_eq!(summary.ran, 0);
assert_eq!(summary.skipped_by_predicate, 0);
assert_eq!(summary.skipped_by_flag, 0);
assert_eq!(summary.failed_ignored, 0);
assert!(process.calls.borrow().is_empty());
}
#[test]
fn dirty_tree_always_errors() {
let local = tempdir().unwrap();
write_commit(
&init_with_commit(local.path()),
"add file",
&[("tracked.txt", b"original")],
);
{
let repo = gix::open(local.path()).expect("open");
let head_tree_id = repo
.head_commit()
.expect("head commit")
.tree_id()
.expect("tree");
let mut idx = repo
.index_from_tree(head_tree_id.as_ref())
.expect("index from tree");
idx.write(Default::default()).expect("write index");
}
fs::write(local.path().join("tracked.txt"), b"modified").unwrap();
let tc_dir = tempdir().unwrap();
let tc_path = make_tool_config(local.path(), &tc_dir);
let state = tempdir().unwrap();
let err = update(&UpdateOpts {
tool_config_path: tc_path,
config_path: Some(local.path().join(".krypt.toml")),
manifest_path: state.path().join("manifest.json"),
dry_run: false,
skip_hooks: false,
force: false,
})
.unwrap_err();
assert!(
matches!(err, UpdateError::DirtyWorkingTree),
"expected DirtyWorkingTree, got {err:?}"
);
}
#[test]
fn tool_config_missing_gives_clear_error() {
let tc_dir = tempdir().unwrap();
let tc_path = tc_dir.path().join("nonexistent.toml");
let state = tempdir().unwrap();
let err = update(&UpdateOpts {
tool_config_path: tc_path.clone(),
config_path: None,
manifest_path: state.path().join("manifest.json"),
dry_run: false,
skip_hooks: false,
force: false,
})
.unwrap_err();
assert!(
matches!(err, UpdateError::ToolConfigMissing { ref path } if path == &tc_path),
"expected ToolConfigMissing, got {err:?}"
);
}
#[test]
fn version_warning_fires_when_older() {
assert!(version_less_than("0.0.2", "99.0.0"));
let warn = version_warning_if_older("99.0.0");
assert!(warn.is_some());
assert!(warn.unwrap().contains("99.0.0"));
}
#[test]
fn version_warning_absent_when_current() {
let our = env!("CARGO_PKG_VERSION");
assert!(version_warning_if_older(our).is_none());
}
#[test]
fn parse_version_basic() {
assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("0.0.0"), Some((0, 0, 0)));
assert!(parse_version("bad").is_none());
}
}