use crate::git;
use crate::ui::{error_sign, valid_sign, warning_sign};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::OnceLock;
static OVERRIDE: OnceLock<Vec<String>> = OnceLock::new();
static NOT_THE_INDEX: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn override_file_set(files: Vec<String>) {
NOT_THE_INDEX.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = OVERRIDE.set(files);
}
pub fn not_the_index() -> bool {
NOT_THE_INDEX.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn staged_files(exts: &[&str]) -> Vec<String> {
if let Some(all) = OVERRIDE.get() {
return all
.iter()
.filter(|f| exts.is_empty() || exts.iter().any(|e| f.ends_with(e)))
.cloned()
.collect();
}
static INDEX: OnceLock<Vec<String>> = OnceLock::new();
INDEX
.get_or_init(|| {
match git::stdout_paths(&["diff", "--diff-filter=d", "--cached", "--name-only"]) {
Some(files) => files,
None => {
warn(
"git would not list the staged files — the checks are judging \
an EMPTY set, not a verified one",
);
Vec::new()
}
}
})
.iter()
.filter(|f| exts.is_empty() || exts.iter().any(|e| f.ends_with(e)))
.cloned()
.collect()
}
pub fn tracked_files() -> Option<Vec<String>> {
static TRACKED: OnceLock<Option<Vec<String>>> = OnceLock::new();
TRACKED
.get_or_init(|| match git::stdout_paths(&["ls-files"]) {
Some(files) => Some(files),
None => {
warn(
"git would not list the repository's files — opt-in gated checks \
will run rather than be skipped on an unverified answer",
);
None
}
})
.clone()
}
pub fn repo_root() -> String {
static ROOT: OnceLock<String> = OnceLock::new();
ROOT.get_or_init(|| {
git::stdout(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| ".".into())
})
.clone()
}
pub fn repo_root_checked() -> Result<String, String> {
git::stdout(&["rev-parse", "--show-toplevel"])
.filter(|s| !s.is_empty())
.ok_or_else(|| "not inside a git repository".to_string())
}
pub fn resolve_tool(root: &str, tool: &str) -> Option<Vec<String>> {
if let Some(p) = in_bin_dir(&format!("{root}/node_modules/.bin"), tool) {
return Some(vec![p]);
}
if let Some(common) = git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])
{
if let Some(main) = Path::new(&common).parent() {
if let Some(p) = in_bin_dir(&main.join("node_modules/.bin").to_string_lossy(), tool) {
return Some(vec![p]);
}
}
}
if let Some(full) = which(tool) {
return Some(vec![full]);
}
if which("npx").is_some()
&& Command::new(program("npx"))
.args(["--no-install", tool, "--version"])
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return Some(vec![
program("npx"),
"--no-install".to_string(),
tool.to_string(),
]);
}
None
}
pub fn which(tool: &str) -> Option<String> {
which_on(&std::env::var_os("PATH")?, tool)
}
pub fn which_on(path: &std::ffi::OsStr, tool: &str) -> Option<String> {
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into())
.split(';')
.filter(|e| !e.is_empty())
.map(|e| e.to_lowercase())
.collect()
} else {
Vec::new()
};
for dir in std::env::split_paths(path) {
for e in &exts {
let c = dir.join(format!("{tool}{e}"));
if c.is_file() {
return Some(c.to_string_lossy().into_owned());
}
}
let bare = dir.join(tool);
if bare.is_file() {
return Some(bare.to_string_lossy().into_owned());
}
}
None
}
fn in_bin_dir(dir: &str, tool: &str) -> Option<String> {
let bare = Path::new(dir).join(tool);
if bare.is_file() {
return Some(bare.to_string_lossy().into_owned());
}
if cfg!(windows) {
for e in [".cmd", ".exe", ".bat", ".ps1"] {
let c = Path::new(dir).join(format!("{tool}{e}"));
if c.is_file() {
return Some(c.to_string_lossy().into_owned());
}
}
}
None
}
pub fn program(name: &str) -> String {
which(name).unwrap_or_else(|| name.to_string())
}
pub fn first_existing(root: &str, names: &[&str]) -> Option<String> {
names
.iter()
.find(|n| Path::new(root).join(n).exists())
.map(|n| (*n).to_string())
}
pub fn strip_git_env(cmd: &mut Command) {
for (k, _) in std::env::vars_os() {
let key = k.to_string_lossy();
if key.starts_with("GIT_") {
cmd.env_remove(&k);
}
}
}
pub fn check_timeout(settings: &crate::config::Settings) -> u64 {
*settings.timeout.get_or_init(|| {
crate::config::integer_or(settings, "amont.timeout", 3600, 0..=86_400) as u64
})
}
pub fn idle_timeout(settings: &crate::config::Settings) -> u64 {
*settings.idle.get_or_init(|| {
crate::config::integer_or(settings, "amont.idleTimeout", 120, 0..=86_400) as u64
})
}
pub fn human_secs(secs: u64) -> String {
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m{:02}s", s / 60, s % 60),
s => format!("{}h{:02}m", s / 3600, (s % 3600) / 60),
}
}
pub struct Activity {
last: std::sync::Mutex<std::time::Instant>,
}
impl Activity {
pub fn new() -> std::sync::Arc<Activity> {
std::sync::Arc::new(Activity {
last: std::sync::Mutex::new(std::time::Instant::now()),
})
}
pub fn touch(&self) {
*self.last.lock().unwrap_or_else(|p| p.into_inner()) = std::time::Instant::now();
}
pub fn quiet_for(&self) -> std::time::Duration {
self.last
.lock()
.unwrap_or_else(|p| p.into_inner())
.elapsed()
}
}
pub fn network_probe_budget(settings: &crate::config::Settings) -> u64 {
match check_timeout(settings) {
0 => 0,
t => t.min(30),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Why {
Ceiling(u64),
Silence(u64),
}
#[derive(Debug, Clone, Copy)]
pub struct Killed {
pub why: Why,
pub ran_secs: u64,
pub quiet_secs: Option<u64>,
}
pub enum Ran {
Status(std::process::ExitStatus),
TimedOut(Killed),
}
pub fn status_within(
settings: &crate::config::Settings,
cmd: &mut Command,
) -> std::io::Result<Ran> {
status_within_secs(cmd, check_timeout(settings))
}
pub fn status_within_secs(cmd: &mut Command, budget_secs: u64) -> std::io::Result<Ran> {
if budget_secs == 0 {
return cmd.status().map(Ran::Status);
}
let mut child = cmd.spawn()?;
wait_within(&mut child, budget_secs, 0, None)
}
fn run_observed(
settings: &crate::config::Settings,
cmd: &mut Command,
on_output: impl Fn(&[u8]) + Send + Sync + 'static,
) -> std::io::Result<Ran> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let activity = Activity::new();
let on_output = std::sync::Arc::new(on_output);
let mut child = cmd.spawn()?;
activity.touch();
let mut readers = Vec::new();
for pipe in [
child
.stdout
.take()
.map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
child
.stderr
.take()
.map(|p| Box::new(p) as Box<dyn std::io::Read + Send>),
]
.into_iter()
.flatten()
{
let activity = std::sync::Arc::clone(&activity);
let on_output = std::sync::Arc::clone(&on_output);
readers.push(std::thread::spawn(move || {
let mut pipe = pipe;
let mut chunk = [0u8; 4096];
loop {
match std::io::Read::read(&mut pipe, &mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => {
activity.touch();
on_output(&chunk[..n]);
}
}
}
}));
}
let ran = wait_within(
&mut child,
check_timeout(settings),
idle_timeout(settings),
Some(&activity),
);
for r in readers {
let _ = r.join();
}
ran
}
pub fn status_streamed(
settings: &crate::config::Settings,
cmd: &mut Command,
) -> std::io::Result<Ran> {
let Some((stage, idx)) = crate::live::current_sink() else {
return status_within(settings, cmd);
};
if crate::live::watching() {
cmd.env("FORCE_COLOR", "1")
.env("CLICOLOR_FORCE", "1")
.env("CARGO_TERM_COLOR", "always");
}
run_observed(settings, cmd, move |bytes| stage.append_raw(idx, bytes))
}
pub fn capture_within(
settings: &crate::config::Settings,
cmd: &mut Command,
) -> Option<(Ran, String)> {
let text = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let sink = std::sync::Arc::clone(&text);
let ran = run_observed(settings, cmd, move |bytes| {
sink.lock()
.unwrap_or_else(|p| p.into_inner())
.push_str(&String::from_utf8_lossy(bytes));
})
.ok()?;
let text = std::sync::Arc::try_unwrap(text)
.map(|m| m.into_inner().unwrap_or_else(|p| p.into_inner()))
.unwrap_or_default();
Some((ran, text))
}
pub(crate) fn wait_within(
child: &mut std::process::Child,
wall_secs: u64,
idle_secs: u64,
activity: Option<&Activity>,
) -> std::io::Result<Ran> {
let started = std::time::Instant::now();
let ceiling = (wall_secs > 0).then(|| started + std::time::Duration::from_secs(wall_secs));
let silence = match activity {
Some(_) if idle_secs > 0 => Some(std::time::Duration::from_secs(idle_secs)),
_ => None,
};
if ceiling.is_none() && silence.is_none() {
return child.wait().map(Ran::Status);
}
loop {
if let Some(status) = child.try_wait()? {
return Ok(Ran::Status(status));
}
let now = std::time::Instant::now();
let quiet = activity.map(|a| a.quiet_for());
let why = judge(
now.duration_since(started),
quiet,
ceiling.map(|_| wall_secs),
silence.map(|_| idle_secs),
);
if let Some(why) = why {
let _ = child.kill();
let _ = child.wait();
return Ok(Ran::TimedOut(Killed {
why,
ran_secs: now.duration_since(started).as_secs(),
quiet_secs: quiet.map(|q| q.as_secs()),
}));
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
pub fn judge(
ran: std::time::Duration,
quiet: Option<std::time::Duration>,
ceiling: Option<u64>,
silence: Option<u64>,
) -> Option<Why> {
if let Some(wall) = ceiling {
if ran >= std::time::Duration::from_secs(wall) {
return Some(Why::Ceiling(wall));
}
}
if let (Some(idle), Some(q)) = (silence, quiet) {
if q >= std::time::Duration::from_secs(idle) {
return Some(Why::Silence(idle));
}
}
None
}
pub fn say_timed_out(what: &str, k: Killed) {
match k.why {
Why::Silence(budget) => fail(&format!(
"{} printed nothing for {} and was killed after {} — a tool this quiet is \
usually stuck, not slow. {} raises the silence budget (0 disables)",
hl(what),
human_secs(budget),
human_secs(k.ran_secs),
hl("git config amont.idleTimeout <secs>")
)),
Why::Ceiling(budget) => {
let verdict = match k.quiet_secs {
Some(q) if q < 30 => format!(
" It was still printing ({} since its last line): slow, not stuck.",
human_secs(q)
),
Some(q) => format!(" Its last output was {} ago.", human_secs(q)),
None => String::new(),
};
fail(&format!(
"{} timed out: ran for {} and was killed at the ceiling. {} raises it \
(0 disables).{verdict}",
hl(what),
human_secs(budget),
hl("git config amont.timeout <secs>")
))
}
}
}
pub fn bounded_success(settings: &crate::config::Settings, cmd: &mut Command, what: &str) -> bool {
match status_streamed(settings, cmd) {
Ok(Ran::Status(s)) => s.success(),
Ok(Ran::TimedOut(b)) => {
say_timed_out(what, b);
false
}
Err(_) => false,
}
}
pub fn run(
settings: &crate::config::Settings,
root: &str,
argv: &[String],
extra: &[String],
) -> bool {
let Some((program, rest)) = argv.split_first() else {
return true;
};
let mut cmd = Command::new(program);
cmd.args(rest)
.args(extra)
.current_dir(root)
.stdin(Stdio::null());
strip_git_env(&mut cmd);
bounded_success(settings, &mut cmd, program)
}
pub fn run_quiet(
settings: &crate::config::Settings,
root: &str,
argv: &[String],
extra: &[String],
) -> bool {
let Some((program, rest)) = argv.split_first() else {
return true;
};
let mut cmd = Command::new(program);
cmd.args(rest)
.args(extra)
.current_dir(root)
.stdin(Stdio::null());
strip_git_env(&mut cmd);
match run_observed(settings, &mut cmd, |_| {}) {
Ok(Ran::Status(s)) => s.success(),
Ok(Ran::TimedOut(b)) => {
say_timed_out(program, b);
false
}
Err(_) => false,
}
}
pub fn fixing_enabled(settings: &crate::config::Settings) -> bool {
!not_the_index() && fixing_requested(settings)
}
pub fn fixing_requested(settings: &crate::config::Settings) -> bool {
*settings
.fixing
.get_or_init(|| crate::config::boolean_or(settings, "amont.fix", false))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Restaged {
Nothing,
Staged,
Failed(Vec<String>),
}
static INDEX_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub fn restage(paths: &[String]) -> Restaged {
if not_the_index() {
return Restaged::Nothing;
}
let changed: Vec<String> = paths
.iter()
.filter(|p| !git::succeeds(&["diff", "--quiet", "--", p]))
.cloned()
.collect();
if changed.is_empty() {
return Restaged::Nothing;
}
let mut args = vec!["add", "--"];
args.extend(changed.iter().map(String::as_str));
let _serialised = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner());
const BACKOFF_MS: [u64; 3] = [50, 150, 400];
if git::succeeds(&args) {
return Restaged::Staged;
}
for wait in BACKOFF_MS {
std::thread::sleep(std::time::Duration::from_millis(wait));
if git::succeeds(&args) {
return Restaged::Staged;
}
}
Restaged::Failed(changed)
}
pub fn ok(settings: &crate::config::Settings, msg: &str) {
if crate::live::quiet(settings) {
return;
}
crate::live::say(&format!("{} {msg}", valid_sign()));
}
pub fn fail(msg: &str) {
crate::live::say(&format!("{} {msg}", error_sign()));
}
pub fn warn(msg: &str) {
crate::live::say(&format!("{} {msg}", warning_sign()));
}
pub fn say(msg: &str) {
crate::live::say(msg);
}
pub fn hl(s: &str) -> String {
crate::ui::highlight(s)
}
#[cfg(test)]
mod tests {
#[test]
fn the_clocks_judge_silence_and_ceiling_separately() {
use super::{judge, Why};
use std::time::Duration as D;
let s = D::from_secs;
assert_eq!(judge(s(900), Some(s(0)), Some(3600), Some(5)), None);
assert_eq!(judge(s(900), Some(s(4)), Some(3600), Some(5)), None);
assert_eq!(
judge(s(30), Some(s(5)), Some(3600), Some(5)),
Some(Why::Silence(5))
);
assert_eq!(judge(s(900), None, Some(3600), Some(5)), None);
assert_eq!(
judge(s(3600), Some(s(0)), Some(3600), Some(120)),
Some(Why::Ceiling(3600))
);
assert_eq!(
judge(s(3600), Some(s(600)), Some(3600), Some(120)),
Some(Why::Ceiling(3600))
);
assert_eq!(judge(s(86_400), Some(s(86_400)), None, None), None);
assert_eq!(judge(s(86_400), Some(s(1)), None, Some(120)), None);
}
#[cfg(unix)]
#[test]
fn the_deadline_kills_a_sleeper_and_spares_a_finisher() {
let started = std::time::Instant::now();
let mut slow = Command::new(program("sleep"));
slow.arg("300").stdin(Stdio::null());
match status_within_secs(&mut slow, 1) {
Ok(Ran::TimedOut(Killed {
why: Why::Ceiling(1),
quiet_secs: None,
..
})) => {}
other => panic!("expected TimedOut(1), got {:?}", other.map(|_| "ran")),
}
assert!(
started.elapsed() < std::time::Duration::from_secs(60),
"the kill did not happen at the deadline"
);
let mut quick = Command::new(program("true"));
quick.stdin(Stdio::null());
match status_within_secs(&mut quick, 60) {
Ok(Ran::Status(s)) => assert!(s.success()),
other => panic!("expected a clean exit, got {:?}", other.map(|_| "?")),
}
}
use super::*;
#[test]
fn which_finds_a_real_binary_and_not_a_fake_one() {
assert!(which("git").is_some());
assert!(which("definitely-not-a-real-binary-xyz").is_none());
}
#[test]
#[cfg(windows)]
fn windows_prefers_an_executable_extension_over_a_bare_file() {
let dir = std::env::temp_dir().join("amont-which-order");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("faketool"), "#!/bin/sh\n").unwrap();
std::fs::write(dir.join("faketool.cmd"), "@echo off\n").unwrap();
let found = which_on(dir.as_os_str(), "faketool").unwrap();
assert!(found.ends_with(".cmd"), "got {found}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn restage_distinguishes_nothing_from_failure() {
let _cwd = crate::TEST_CWD.lock().unwrap_or_else(|p| p.into_inner());
let outside = std::env::temp_dir()
.join("amont-restage-outside-any-repo")
.to_string_lossy()
.into_owned();
assert_eq!(
restage(std::slice::from_ref(&outside)),
Restaged::Failed(vec![outside]),
"a `git add` git refuses must report Failed, never Nothing"
);
assert_eq!(
restage(&[]),
Restaged::Nothing,
"no paths is nothing to do, and nothing wrong"
);
}
#[test]
fn no_hook_spawns_a_bare_program_name() {
let needle = concat!("Command", "::new(");
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/hooks");
let mut scanned = 0usize;
for entry in std::fs::read_dir(dir).expect("hooks dir").flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
scanned += 1;
let src = std::fs::read_to_string(&path).expect("read a hook module");
for (n, line) in src.lines().enumerate() {
if line.trim_start().starts_with("//") {
continue;
}
let Some(after) = line.split_once(needle) else {
continue;
};
assert!(
!after.1.starts_with('"'),
"{}:{} spawns a bare name — route it through `program()` or \
the path `which()` already resolved: {}",
path.display(),
n + 1,
line.trim()
);
}
}
assert!(
scanned > 10,
"the scan found almost nothing: {scanned} files"
);
}
#[test]
fn first_existing_picks_the_earliest_present_name() {
let dir = std::env::temp_dir().join("amont-first-existing-test");
let _ = std::fs::create_dir_all(&dir);
let root = dir.to_string_lossy().into_owned();
let _ = std::fs::write(dir.join("second"), "x");
assert_eq!(
first_existing(&root, &["first", "second", "third"]).as_deref(),
Some("second")
);
assert_eq!(first_existing(&root, &["nope"]), None);
let _ = std::fs::remove_dir_all(&dir);
}
}