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 const SNAPSHOT_ENV: &str = "AMONT_SNAPSHOT";
const MARKER: &str = "amont-snapshot";
fn snapshot_marker_path(dir: &Path) -> std::io::Result<PathBuf> {
let mut cmd = std::process::Command::new("git");
cmd.arg("-C")
.arg(dir)
.args(["rev-parse", "--path-format=absolute", "--git-path", MARKER])
.stdin(std::process::Stdio::null());
crate::hooks::common::strip_git_env(&mut cmd);
let out = cmd.output()?;
if !out.status.success() {
let why = String::from_utf8_lossy(&out.stderr);
let why = why
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("no reason given");
return Err(std::io::Error::other(format!(
"git would not say where the snapshot marker lives: {}",
crate::ui::sanitize(why)
)));
}
let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
if p.is_empty() {
return Err(std::io::Error::other(
"git gave an empty path for the snapshot marker",
));
}
let p = PathBuf::from(p);
Ok(if p.is_absolute() { p } else { dir.join(p) })
}
pub fn probe_marker(path: &Path) -> std::io::Result<bool> {
match std::fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e),
}
}
pub fn in_push_snapshot(dir: &Path) -> std::io::Result<bool> {
probe_marker(&snapshot_marker_path(dir)?)
}
fn mark_snapshot(dir: &Path) -> std::io::Result<()> {
std::fs::write(snapshot_marker_path(dir)?, b"")
}
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,
tip: String,
}
impl PushedTree {
pub fn create(
settings: &crate::config::Settings,
repo: &Path,
tip: &str,
) -> Option<PushedTree> {
let tree = Self::checkout(repo, tip)?;
match tree.prepare(settings) {
Ok(()) => Some(tree),
Err(why) => {
println!("{} could not prepare the snapshot: {why}", warning_sign());
None
}
}
}
pub fn checkout(repo: &Path, tip: &str) -> Option<PushedTree> {
let base = std::env::temp_dir().join(unique_name("amont-push"));
Self::checkout_at(base, repo, tip)
}
fn checkout_at(base: PathBuf, repo: &Path, tip: &str) -> Option<PushedTree> {
Self::checkout_at_with(base, repo, tip, &mark_snapshot)
}
fn checkout_at_with(
base: PathBuf,
repo: &Path,
tip: &str,
mark: &dyn Fn(&Path) -> std::io::Result<()>,
) -> 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(),
tip: tip.to_string(),
};
if let Err(e) = mark(&tree.path) {
println!("{} could not mark the snapshot: {e}", warning_sign());
return None;
}
Some(tree)
}
pub fn prepare(&self, settings: &crate::config::Settings) -> Result<(), String> {
let script = prepare_command(settings);
crate::snapshot_prep::run(
settings,
&self.repo,
&self.path,
&self.tip,
script.is_some(),
)?;
let Some(script) = script else {
return Ok(());
};
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);
cmd.env(SNAPSHOT_ENV, "1");
if crate::hooks::common::bounded_success(settings, &mut cmd, PREPARE) {
Ok(())
} else {
Err(format!("{PREPARE} failed in the snapshot"))
}
}
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::checkout_at(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);
}
fn seeded(name: &str) -> (PathBuf, String) {
let d = repo(name);
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&d)
.output()
.expect("git")
};
git(&["commit", "--allow-empty", "-qm", "seed"]);
let head = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout)
.trim()
.to_string();
(d, head)
}
#[test]
fn a_snapshot_is_marked_and_its_source_is_not() {
let (d, head) = seeded("marked");
let tree = PushedTree::checkout(&d, &head).expect("worktree");
assert!(in_push_snapshot(tree.path()).expect("probe"));
assert!(!in_push_snapshot(&d).expect("probe"));
drop(tree);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn an_unmarkable_snapshot_is_no_snapshot() {
let (d, head) = seeded("unmarkable");
let base = std::env::temp_dir().join(unique_name("amont-push-unmarkable"));
let name = base.file_name().unwrap().to_string_lossy().into_owned();
let got = PushedTree::checkout_at_with(base.clone(), &d, &head, &|_| {
Err(std::io::Error::other("cannot mark"))
});
assert!(got.is_none(), "an unmarked snapshot was handed out");
assert!(
!base.exists(),
"the snapshot directory outlived the refusal"
);
let list = std::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(&d)
.output()
.expect("git");
let list = String::from_utf8_lossy(&list.stdout);
assert!(!list.contains(&name), "still registered:\n{list}");
let _ = std::fs::remove_dir_all(&d);
}
#[cfg(unix)]
fn is_root() -> bool {
std::process::Command::new("id")
.arg("-u")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "0")
.unwrap_or(false)
}
#[cfg(unix)]
#[test]
fn a_marker_that_cannot_be_looked_at_is_an_error_not_absent() {
use std::os::unix::fs::PermissionsExt;
if is_root() {
eprintln!("skipped: root ignores directory permissions");
return;
}
struct Restore(PathBuf);
impl Drop for Restore {
fn drop(&mut self) {
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
if let Some(parent) = self.0.parent() {
let _ = std::fs::remove_dir_all(parent);
}
}
}
let d = std::env::temp_dir().join(unique_name("amont-probe"));
let locked = d.join("locked");
std::fs::create_dir_all(&locked).expect("mkdir");
let marker = locked.join(MARKER);
std::fs::write(&marker, "").expect("marker");
assert!(probe_marker(&marker).expect("readable"), "present");
assert!(
!probe_marker(&locked.join("nope")).expect("readable"),
"absent"
);
let _guard = Restore(locked.clone());
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod");
let got = probe_marker(&marker);
assert_eq!(
got.expect_err("a locked directory is not an absent marker")
.kind(),
std::io::ErrorKind::PermissionDenied
);
}
}