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();
}
let Some(out) = git::stdout_paths(&["diff", "--diff-filter=d", "--cached", "--name-only"])
else {
return Vec::new();
};
out.into_iter()
.filter(|f| exts.is_empty() || exts.iter().any(|e| f.ends_with(e)))
.collect()
}
pub fn repo_root() -> String {
git::stdout(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| ".".into())
}
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> {
let path = std::env::var_os("PATH")?;
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 run(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);
cmd.status().map(|s| s.success()).unwrap_or(false)
}
pub fn run_quiet(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())
.stdout(Stdio::null())
.stderr(Stdio::null());
strip_git_env(&mut cmd);
cmd.status().map(|s| s.success()).unwrap_or(false)
}
pub fn fixing_enabled() -> bool {
!not_the_index() && fixing_requested()
}
pub fn fixing_requested() -> bool {
crate::config::boolean_or("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(msg: &str) {
println!("{} {msg}", valid_sign());
}
pub fn fail(msg: &str) {
println!("{} {msg}", error_sign());
}
pub fn warn(msg: &str) {
println!("{} {msg}", warning_sign());
}
pub fn hl(s: &str) -> String {
crate::ui::highlight(s)
}
#[cfg(test)]
mod tests {
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 saved = std::env::var_os("PATH");
std::env::set_var("PATH", &dir);
let found = which("faketool").unwrap();
if let Some(p) = saved {
std::env::set_var("PATH", p);
}
assert!(found.ends_with(".cmd"), "got {found}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn restage_distinguishes_nothing_from_failure() {
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);
}
}