use std::ffi::{OsStr, OsString};
use std::path::Path;
use std::process::{Command, Stdio};
use anyhow::Context;
use crate::cli::i18n;
use crate::hooks::files::{last_pull_paths, last_push_paths, should_execute, write_last_execution};
use crate::models::configuration::{Configuration, StoreRegistration};
pub struct HookExecutor<'a> {
pub configuration: &'a Configuration,
pub registration: &'a StoreRegistration,
pub offline: bool,
pub force: bool,
}
impl HookExecutor<'_> {
pub fn execute_pull_commands(&self) -> anyhow::Result<()> {
if !&self.configuration.pull_commands.is_empty()
|| !&self.registration.pull_commands.is_empty()
{
if let Some(store_name) = self.registration.path().file_name() {
if self.force || (!self.offline && self.should_pull(store_name)?) {
i18n::execute_pull_hooks(&self.registration.name);
self.execute(&self.configuration.pull_commands)?;
self.execute(&self.registration.pull_commands)?;
if self.force {
write_last_execution(last_pull_paths(store_name))?;
}
}
Ok(())
} else {
anyhow::bail!(i18n::error_cannot_determine_store_name())
}
} else {
Ok(())
}
}
pub fn execute_push_commands(&self) -> anyhow::Result<()> {
if !&self.configuration.push_commands.is_empty()
|| !&self.registration.push_commands.is_empty()
{
if let Some(store_name) = self.registration.path().file_name() {
if self.force || (!self.offline && self.should_push(store_name)?) {
i18n::execute_push_hooks(&self.registration.name);
self.execute(&self.configuration.push_commands)?;
self.execute(&self.registration.push_commands)?;
if self.force {
write_last_execution(last_push_paths(store_name))?;
}
}
Ok(())
} else {
anyhow::bail!(i18n::error_cannot_determine_store_name())
}
} else {
Ok(())
}
}
fn execute(&self, commands: &[String]) -> anyhow::Result<()> {
if commands.is_empty() {
return Ok(());
}
let store_path = self.registration.path();
let store_path_display = store_path.display().to_string();
let parent = store_path
.parent()
.with_context(|| i18n::error_cannot_determine_store_parent_path(&store_path_display))?;
for command in commands {
let command_display = format!("{command:?}");
let Some(template) = shlex::split(command) else {
anyhow::bail!(i18n::error_cannot_parse_hook_command(&command_display));
};
let args = build_args(&template, store_path)?;
let (binary, rest) = args
.split_first()
.with_context(|| i18n::error_empty_hook_command(&command_display))?;
let output = Command::new(binary)
.args(rest)
.stdout(Stdio::null())
.current_dir(parent)
.output()
.with_context(|| i18n::error_failed_to_run_hook(&command_display))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
let exit = output
.status
.code()
.map_or_else(|| String::from("signal"), |c| c.to_string());
if detail.is_empty() {
anyhow::bail!(i18n::error_hook_failed_no_detail(&command_display, &exit));
}
anyhow::bail!(i18n::error_hook_failed_with_detail(
&command_display,
&exit,
detail
));
}
}
Ok(())
}
fn should_pull(&self, store_name: &OsStr) -> anyhow::Result<bool> {
should_execute(
self.configuration.pull_interval_seconds,
last_pull_paths(store_name),
)
}
fn should_push(&self, store_name: &OsStr) -> anyhow::Result<bool> {
should_execute(
self.configuration.push_interval_seconds,
last_push_paths(store_name),
)
}
}
fn build_args(template: &[String], path: &Path) -> anyhow::Result<Vec<OsString>> {
let path_display = path.display().to_string();
let mut out = Vec::with_capacity(template.len());
for token in template {
if token == "%p" {
out.push(path.as_os_str().to_os_string());
} else if token.contains("%p") {
let token_display = format!("{token:?}");
let path_str = path
.to_str()
.with_context(|| i18n::error_store_path_not_utf8(&token_display, &path_display))?;
out.push(OsString::from(token.replace("%p", path_str)));
} else {
out.push(OsString::from(token));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn build_args_passes_path_with_metacharacters_as_single_arg() {
let template = shlex::split("git add %p").unwrap();
let path = PathBuf::from("/tmp/pasejo'; touch /tmp/PWNED #/store.age");
let args = build_args(&template, &path).unwrap();
assert_eq!(args.len(), 3);
assert_eq!(args[0], OsString::from("git"));
assert_eq!(args[1], OsString::from("add"));
assert_eq!(args[2], path.as_os_str());
}
#[test]
fn build_args_substitutes_inside_token() {
let template = shlex::split("git --git-dir=%p/.git status").unwrap();
let path = PathBuf::from("/tmp/store");
let args = build_args(&template, &path).unwrap();
assert_eq!(args.len(), 3);
assert_eq!(args[0], OsString::from("git"));
assert_eq!(args[1], OsString::from("--git-dir=/tmp/store/.git"));
assert_eq!(args[2], OsString::from("status"));
}
#[test]
fn build_args_leaves_unrelated_tokens_alone() {
let template = shlex::split("echo hello world").unwrap();
let path = PathBuf::from("/tmp/store");
let args = build_args(&template, &path).unwrap();
assert_eq!(
args,
vec![
OsString::from("echo"),
OsString::from("hello"),
OsString::from("world"),
]
);
}
#[test]
fn build_args_handles_quoted_token_with_path() {
let template = shlex::split("git commit -m \"changed %p\"").unwrap();
let path = PathBuf::from("/tmp/store");
let args = build_args(&template, &path).unwrap();
assert_eq!(args[0], OsString::from("git"));
assert_eq!(args[1], OsString::from("commit"));
assert_eq!(args[2], OsString::from("-m"));
assert_eq!(args[3], OsString::from("changed /tmp/store"));
}
}