use std::collections::BTreeMap;
use sha2::{Digest, Sha256};
use crate::prelude::*;
use crate::state::stamp_config::{Hook, HookInputs};
pub const RECORD: &str = "local-prepare.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Prepared {
pub fingerprint: BTreeMap<String, String>,
pub prepared_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Always,
Unchanged { since: String },
Run { reason: String },
}
pub fn decide(hook: &Hook, scan_root: &Path, state_dir: &Path) -> Result<(Decision, Option<BTreeMap<String, String>>)> {
let Some(inputs) = hook.inputs() else {
return Ok((Decision::Always, None));
};
let now = fingerprint(hook, inputs, scan_root)?;
let decision = match read(state_dir)? {
None => Decision::Run { reason: "not prepared here since the tree was last released".to_string() },
Some(record) => match changed(&record.fingerprint, &now) {
Some(reason) => Decision::Run { reason },
None => match hook.produces().iter().find(|path| !scan_root.join(path).exists()) {
Some(gone) => Decision::Run { reason: format!("{} is not there", gone.display()) },
None => Decision::Unchanged { since: record.prepared_at },
},
},
};
Ok((decision, Some(now)))
}
fn changed(before: &BTreeMap<String, String>, now: &BTreeMap<String, String>) -> Option<String> {
let mut changed: Vec<&str> = now
.iter()
.filter(|(input, value)| before.get(*input) != Some(*value))
.map(|(input, _)| input.as_str())
.collect();
changed.extend(before.keys().filter(|input| !now.contains_key(*input)).map(String::as_str));
if changed.is_empty() {
return None;
}
let shown: Vec<&str> = changed.iter().take(3).copied().collect();
let more = if changed.len() > 3 { format!(" and {} more", changed.len() - 3) } else { String::new() };
Some(format!("{}{more} changed", shown.join(", ")))
}
fn fingerprint(hook: &Hook, inputs: &HookInputs, scan_root: &Path) -> Result<BTreeMap<String, String>> {
let mut found = BTreeMap::new();
found.insert("command".to_string(), hook.argv().join("\u{1f}"));
for repository in &inputs.repositories {
let dir = scan_root.join(repository);
found.insert(format!("repository {}", repository.display()), working_tree_id(&dir)?);
}
for file in &inputs.files {
let path = scan_root.join(file);
let value = match std::fs::read(&path) {
Ok(bytes) => format!("{:x}", Sha256::digest(&bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => "absent".to_string(),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
found.insert(format!("file {}", file.display()), value);
}
for program in &inputs.programs {
let value = match crate::report::which(program) {
None => "absent".to_string(),
Some(path) => {
let resolved = crate::platform::canonicalize(&path).unwrap_or_else(|_| PathBuf::from(&path));
let meta = std::fs::metadata(&resolved).with_context(|| format!("reading {}", resolved.display()))?;
let modified = meta
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|since| since.as_nanos())
.unwrap_or_default();
format!("{path} -> {} ({} bytes, {modified})", resolved.display(), meta.len())
}
};
found.insert(format!("program {program}"), value);
}
Ok(found)
}
pub fn working_tree_id(repository: &Path) -> Result<String> {
let git_dir = git_in(repository, &["rev-parse", "--path-format=absolute", "--git-dir"], None)
.with_context(|| format!("{} is not a repository a prepare step can read", repository.display()))?;
let scratch = tempfile::tempdir().context("a scratch folder for an index")?;
let index = scratch.path().join("index");
let real = PathBuf::from(git_dir.trim()).join("index");
if real.is_file() {
std::fs::copy(&real, &index).with_context(|| format!("copying {}", real.display()))?;
let written = std::fs::metadata(&real)
.and_then(|meta| meta.modified())
.with_context(|| format!("reading {}", real.display()))?;
std::fs::File::options()
.write(true)
.open(&index)
.and_then(|file| file.set_modified(written))
.with_context(|| format!("timing {}", index.display()))?;
}
git_in(repository, &["add", "--all", "--", "."], Some(&index))?;
Ok(git_in(repository, &["write-tree"], Some(&index))?.trim().to_string())
}
fn git_in(dir: &Path, args: &[&str], index: Option<&Path>) -> Result<String> {
let mut command = crate::git_backend::git_command();
command.arg("-C").arg(dir).args(args);
if let Some(index) = index {
command.env("GIT_INDEX_FILE", index);
}
let output = command.output().with_context(|| format!("running git in {}", dir.display()))?;
if !output.status.success() {
bail!("git {} in {} failed: {}", args.join(" "), dir.display(), String::from_utf8_lossy(&output.stderr).trim());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn read(state_dir: &Path) -> Result<Option<Prepared>> {
let path = state_dir.join(RECORD);
match std::fs::read_to_string(&path) {
Ok(text) => Ok(serde_json::from_str(&text).ok()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
pub fn record(state_dir: &Path, fingerprint: BTreeMap<String, String>) -> Result<()> {
std::fs::create_dir_all(state_dir).with_context(|| format!("creating {}", state_dir.display()))?;
let path = state_dir.join(RECORD);
let record = Prepared { fingerprint, prepared_at: chrono::Local::now().to_rfc3339() };
let staged = path.with_extension("json.tmp");
std::fs::write(&staged, serde_json::to_string_pretty(&record)?).with_context(|| format!("writing {}", staged.display()))?;
std::fs::rename(&staged, &path).with_context(|| format!("writing {}", path.display()))
}
pub fn forget(state_dir: &Path) -> Result<()> {
match std::fs::remove_file(state_dir.join(RECORD)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| format!("removing {}", state_dir.join(RECORD).display())),
}
}
pub fn shown_time(since: &str) -> String {
match chrono::DateTime::parse_from_rfc3339(since) {
Ok(at) => {
let at = at.with_timezone(&chrono::Local);
if at.date_naive() == chrono::Local::now().date_naive() {
at.format("%H:%M").to_string()
} else {
at.format("%Y-%m-%d %H:%M").to_string()
}
}
Err(_) => since.to_string(),
}
}
#[cfg(test)]
#[path = "local_prepare_tests.rs"]
mod tests;