use std::path::{Path, PathBuf};
use crate::ui::warning_sign;
pub fn enabled(settings: &crate::config::Settings) -> bool {
crate::config::boolean_or(settings, "amont.testPushedTree", false)
}
const PREPARE: &str = "amont.snapshotPrepare";
pub const SOURCE_WORKTREE_ENV: &str = "AMONT_SOURCE_WORKTREE";
pub fn prepare_command(settings: &crate::config::Settings) -> Option<String> {
crate::config::string_value(settings, PREPARE).filter(|s| !s.trim().is_empty())
}
pub struct PushedTree {
path: PathBuf,
repo: PathBuf,
}
impl PushedTree {
pub fn create(
settings: &crate::config::Settings,
repo: &Path,
tip: &str,
) -> Option<PushedTree> {
let base = std::env::temp_dir().join(unique_name("amont-push"));
Self::create_at(settings, base, repo, tip)
}
fn create_at(
settings: &crate::config::Settings,
base: PathBuf,
repo: &Path,
tip: &str,
) -> Option<PushedTree> {
std::fs::create_dir(&base).ok()?;
let ok = crate::git::succeeds(&[
"-C",
repo.to_str()?,
"worktree",
"add",
"--detach",
"--quiet",
base.to_str()?,
tip,
]);
if !ok {
let _ = std::fs::remove_dir_all(&base);
return None;
}
let tree = PushedTree {
path: base,
repo: repo.to_path_buf(),
};
if !tree.prepare(settings) {
return None;
}
Some(tree)
}
fn prepare(&self, settings: &crate::config::Settings) -> bool {
let Some(script) = prepare_command(settings) else {
return true;
};
println!("preparing the snapshot: {script}");
#[cfg(unix)]
let mut cmd = {
let mut c = std::process::Command::new("sh");
c.arg("-c").arg(&script);
c
};
#[cfg(not(unix))]
let mut cmd = {
let mut c = std::process::Command::new("cmd");
c.arg("/C").arg(&script);
c
};
cmd.current_dir(&self.path)
.stdin(std::process::Stdio::null());
crate::hooks::common::strip_git_env(&mut cmd);
cmd.env(SOURCE_WORKTREE_ENV, &self.repo);
let ok = crate::hooks::common::bounded_success(settings, &mut cmd, PREPARE);
if !ok {
println!(
"{} {PREPARE} failed in the snapshot — nothing can be tested there",
warning_sign()
);
}
ok
}
pub fn path(&self) -> &Path {
&self.path
}
}
fn unique_name(prefix: &str) -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hash, Hasher};
let pid = std::process::id();
let mut hasher = RandomState::new().build_hasher();
pid.hash(&mut hasher);
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.hash(&mut hasher);
format!("{prefix}-{pid}-{:016x}", hasher.finish())
}
impl Drop for PushedTree {
fn drop(&mut self) {
let _ = crate::git::succeeds(&[
"-C",
self.repo.to_str().unwrap_or_default(),
"worktree",
"remove",
"--force",
self.path.to_str().unwrap_or_default(),
]);
let _ = std::fs::remove_dir_all(&self.path);
}
}
static SNAPSHOTTED: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashSet<(String, String)>>,
> = std::sync::OnceLock::new();
fn snapshotted() -> &'static std::sync::Mutex<std::collections::HashSet<(String, String)>> {
SNAPSHOTTED.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
fn note_snapshot(gate: &str, tip: &str) {
if let Ok(mut set) = snapshotted().lock() {
set.insert((gate.to_string(), tip.to_string()));
}
}
pub fn ran_on_tip(gate: &str, tip: &str) -> bool {
match snapshotted().lock() {
Ok(set) => set.contains(&(gate.to_string(), tip.to_string())),
Err(_) => false,
}
}
pub fn where_to_run(
settings: &crate::config::Settings,
tip: &str,
fallback: &str,
gate: &str,
) -> (PathBuf, Option<PushedTree>) {
if crate::rehearsal::in_snapshot() {
return (PathBuf::from(fallback), None);
}
if !enabled(settings) {
static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if !SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
println!(
"{} testing the WORKING TREE, not the pushed commits \
(`git config amont.testPushedTree true` to test what you are pushing)",
warning_sign()
);
}
return (PathBuf::from(fallback), None);
}
match PushedTree::create(settings, Path::new(fallback), tip) {
Some(tree) => {
note_snapshot(gate, tip);
let path = tree.path().to_path_buf();
(path, Some(tree))
}
None => {
println!(
"{} could not check out {tip} to test it; testing the working tree instead \
(nothing will be stamped for it)",
warning_sign()
);
(PathBuf::from(fallback), None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_settings() -> crate::config::Settings {
crate::config::Settings::default()
}
#[test]
fn unique_name_does_not_repeat() {
let a = unique_name("amont-push");
let b = unique_name("amont-push");
assert_ne!(a, b);
assert!(a.starts_with("amont-push-"));
}
#[test]
fn an_existing_path_is_left_alone_not_cleared() {
let base = std::env::temp_dir().join(format!("pushed-collision-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("sentinel.txt"), "do not delete me").unwrap();
let got = PushedTree::create_at(
&test_settings(),
base.clone(),
Path::new("/does/not/matter"),
"HEAD",
);
assert!(
got.is_none(),
"must refuse rather than reuse a path it did not create"
);
assert_eq!(
std::fs::read_to_string(base.join("sentinel.txt")).unwrap(),
"do not delete me",
"an existing path must never be cleared to make room"
);
let _ = std::fs::remove_dir_all(&base);
}
fn repo(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pushed-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
for args in [
vec!["init", "-q", "--template=", "."],
vec!["config", "user.email", "t@t.test"],
vec!["config", "user.name", "t"],
vec!["config", "core.autocrlf", "false"],
] {
std::process::Command::new("git")
.args(&args)
.current_dir(&d)
.output()
.expect("git");
}
d
}
#[test]
fn the_worktree_holds_the_committed_content() {
let d = repo("tree");
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&d)
.output()
.expect("git")
};
std::fs::write(d.join("a.txt"), "committed\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-qm", "seed"]);
let head = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
std::fs::write(d.join("a.txt"), "dirty, not pushed\n").unwrap();
let tree = PushedTree::create(&test_settings(), &d, &head).expect("worktree");
let seen = std::fs::read_to_string(tree.path().join("a.txt")).unwrap();
let at = tree.path().to_path_buf();
drop(tree);
assert_eq!(seen, "committed\n", "the worktree saw the dirty tree");
assert!(!at.exists(), "the worktree outlived its guard");
let _ = std::fs::remove_dir_all(&d);
}
}