use std::path::{Path, PathBuf};
use eyre::{Result, bail};
use crate::file::display_path;
use crate::system::history::checkpoint::{Draft, Outcome, Store};
use crate::system::history::store::{DescriptionSource, Entry, Trigger};
use crate::system::history::tracked::{display_to_tree_path, normalize_target};
#[derive(Debug, usage_rs::Args)]
#[usage(verbatim_doc_comment)]
pub(crate) struct DotfilesSave {
#[usage(value_name = "PATH")]
paths: Vec<PathBuf>,
#[usage(long, short, value_name = "TEXT")]
description: Option<String>,
#[usage(long, value_name = "TRIGGER", default = "save")]
trigger: String,
#[usage(long, value_name = "ID")]
task: Option<String>,
#[usage(long, value_name = "LABEL")]
label: Vec<String>,
#[usage(long)]
best_effort: bool,
}
impl DotfilesSave {
pub(crate) async fn run(self) -> Result<()> {
self.save().await
}
fn save_checkpoint(
&self,
store: &Store,
tracked: &crate::system::history::tracked::TrackedSet,
draft: Draft,
) -> Result<()> {
match self.capture(store, tracked, draft) {
Ok(()) => Ok(()),
Err(err) if self.best_effort => {
warn!("history save: {err:#}");
Ok(())
}
Err(err) => Err(err),
}
}
async fn save(self) -> Result<()> {
let trigger = match self.trigger.as_str() {
"save" => Trigger::Save,
"agent" => Trigger::Agent,
"update" => Trigger::Update,
other => bail!("unknown trigger {other:?}; use save, agent, or update"),
};
if !crate::config::Settings::get().history.enabled {
bail!("history is disabled (history.enabled = false)");
}
let (store, tracked, entries) = super::history::open().await?;
if !self.paths.is_empty() {
let walk = tracked.walk()?;
for path in &self.paths {
let path = normalize_target(path);
let captured = walk.files.keys().any(|file| file.starts_with(&path));
if !captured {
let deletion = tracked
.entry_for(&path)
.is_some_and(|entry| !entry.policy.autosave)
&& std::fs::symlink_metadata(&path)
.is_err_and(|err| err.kind() == std::io::ErrorKind::NotFound)
&& previously_captured(&store, &entries, &path)?;
if deletion {
continue;
}
let reason = if tracked.entry_for(&path).is_some() {
"it is excluded, missing, or omitted from capture"
} else {
"track it with `mise bootstrap dotfiles track`"
};
bail!("{} is not captured; {reason}", display_path(&path));
}
}
}
let mut draft = Draft::new(trigger);
draft.explicit_paths = self
.paths
.iter()
.map(|path| normalize_target(path))
.collect();
draft.description = self.description.clone();
draft.description_source = Some(if trigger == Trigger::Agent {
DescriptionSource::Agent
} else {
DescriptionSource::User
});
draft.task = self.task.clone();
draft.labels = self.label.clone();
tokio::task::spawn_blocking(move || self.save_checkpoint(&store, &tracked, draft)).await?
}
fn capture(
&self,
store: &Store,
tracked: &crate::system::history::tracked::TrackedSet,
draft: Draft,
) -> Result<()> {
if let Some(reason) = store.unavailable() {
bail!("cannot save: {reason}");
}
let _operation = crate::system::history::scope::take_operation_lock(store, tracked)?;
match store.attempt(tracked, draft)? {
Outcome::Created(entry) => {
info!(
"history: saved checkpoint {}: {}",
entry.id, entry.checkpoint.description
);
Ok(())
}
Outcome::Unchanged => {
info!("history: nothing changed since the latest checkpoint");
Ok(())
}
Outcome::Unavailable(reason) => bail!("cannot save: {reason}"),
}
}
}
fn previously_captured(store: &Store, entries: &[Entry], path: &Path) -> Result<bool> {
let Some(repo) = store.repo() else {
return Ok(false);
};
let Some(snapshot) = entries
.iter()
.rev()
.find_map(|entry| entry.checkpoint.tree.snapshot.as_ref())
else {
return Ok(false);
};
let tree_path = display_to_tree_path(&display_path(path));
Ok(repo.object_at(snapshot, &tree_path)?.is_some())
}