use std::path::Path;
use serde::Deserialize;
use crate::error::Error;
use crate::paths::Tree;
use crate::shared::{self, FromCheckout, printable};
use crate::state::State;
use crate::{flockfile, git, hook};
const FLOCKFILE: &str = "Flockfile.toml";
#[derive(Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ReloadSpec {
pub paths: Vec<String>,
}
fn parse(doc: &toml::Value, release: &Path) -> Result<ReloadSpec, Error> {
let Some(table) = hook::deploy_table(doc, "reload") else {
return Ok(ReloadSpec::default());
};
table.clone().try_into().map_err(|source: toml::de::Error| {
Error::Config(format!(
"{}: `[dog.deploy.reload]` does not match the reload schema: {}",
release.display(),
printable(source.message())
))
})
}
pub(crate) fn changed(
git_dir: &Path,
release: &Path,
shared: &FromCheckout,
from: &str,
to: &str,
) -> Result<bool, Error> {
let flockfiles = flockfile::read(release, shared)?;
let paths = parse(&flockfiles.doc, release)?.paths;
if paths.is_empty() {
return Ok(true);
}
Ok(git::changed(git_dir, from, to, &[FLOCKFILE.to_owned()])?
|| git::changed(git_dir, from, to, &paths)?)
}
pub(crate) async fn nothing_changed(tree: &Tree, state: &State, live: &Path, head: &str) -> bool {
let Some(deployed) = state.deployed.clone() else {
return false;
};
let (git_dir, live, checkout, head_sha) = (
tree.git(),
live.to_owned(),
state.checkout.clone(),
head.to_owned(),
);
let decided = shared::off_thread(move || {
let linked = shared::to_link(&checkout)?;
changed(&git_dir, &live, &linked, &deployed, &head_sha)
})
.await;
match decided {
Ok(changed) => !changed,
Err(err) => {
eprintln!(
"shep-deploy: {}: could not tell whether {head} touches the reload paths, \
deploying it: {err}",
tree.sheep()
);
false
}
}
}
#[cfg(test)]
mod tests {
use crate::error::Error;
use crate::fixtures;
use crate::shared::FromCheckout;
use super::*;
const APP: &str = "[[app]]\nname = \"web\"\nscript = \"./run\"\n";
fn release_with(extra: &str) -> tempfile::TempDir {
fixtures::fixture_release(&[("Flockfile.toml", &format!("{APP}{extra}"))])
}
const VOTING: &str = "[dog.deploy.reload]\npaths = [\"cmd/voting\", \"assets/*.png\"]\n";
fn pushed(files: &[(&str, &str)]) -> (tempfile::TempDir, String, String) {
let repo = fixtures::checkout(&[
("cmd/voting/main.go", "1"),
("cmd/bot/main.go", "1"),
("assets/logo.png", "1"),
]);
let from = fixtures::head_of(repo.path());
fixtures::commit(repo.path(), files, "push");
let to = fixtures::head_of(repo.path());
(repo, from, to)
}
fn decide(release: &tempfile::TempDir, repo: &tempfile::TempDir, from: &str, to: &str) -> bool {
changed(
repo.path(),
release.path(),
&FromCheckout::of(Vec::new()),
from,
to,
)
.expect("decides")
}
#[test]
fn a_push_outside_the_paths_is_unchanged() {
let (repo, from, to) = pushed(&[("cmd/bot/main.go", "2")]);
assert!(!decide(&release_with(VOTING), &repo, &from, &to));
}
#[test]
fn a_push_inside_a_path_is_changed() {
let release = release_with(VOTING);
let (repo, from, to) = pushed(&[("cmd/voting/main.go", "2")]);
assert!(
decide(&release, &repo, &from, &to),
"a directory name matches its contents"
);
let (repo, from, to) = pushed(&[("assets/logo.png", "2")]);
assert!(decide(&release, &repo, &from, &to), "a glob");
}
#[test]
fn an_edit_to_the_flockfile_is_always_changed() {
let (repo, from, to) = pushed(&[("Flockfile.toml", "edited")]);
assert!(decide(&release_with(VOTING), &repo, &from, &to));
}
#[test]
fn an_exclusion_cannot_hide_the_flockfile() {
let (repo, from, to) = pushed(&[("Flockfile.toml", "edited")]);
let release = release_with(
"[dog.deploy.reload]\npaths = [\"cmd/voting\", \":(exclude)Flockfile.toml\"]\n",
);
assert!(decide(&release, &repo, &from, &to));
}
#[test]
fn no_table_and_an_empty_list_both_mean_deploy() {
let (repo, from, to) = pushed(&[("cmd/bot/main.go", "2")]);
assert!(decide(&release_with(""), &repo, &from, &to), "no table");
assert!(
decide(
&release_with("[dog.deploy.reload]\npaths = []\n"),
&repo,
&from,
&to
),
"an empty list"
);
assert!(
decide(&release_with("[dog.deploy.reload]\n"), &repo, &from, &to),
"an empty table"
);
}
#[test]
fn an_unknown_key_in_the_table_is_refused_by_name() {
let (repo, from, to) = pushed(&[("cmd/bot/main.go", "2")]);
let release = release_with("[dog.deploy.reload]\npths = [\"cmd/voting\"]\n");
let err = changed(
repo.path(),
release.path(),
&FromCheckout::of(Vec::new()),
&from,
&to,
)
.expect_err("a typo is refused");
assert!(matches!(err, Error::Config(_)), "{err}");
assert!(err.to_string().contains("pths"), "{err}");
}
}