use std::cell::RefCell;
use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Instant;
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const MAX_LINES: usize = 12;
struct Slot {
name: String,
started: Instant,
last_output: Instant,
next_beat: u64,
buf: Vec<u8>,
running: bool,
done: bool,
activity: Option<Arc<crate::hooks::common::Activity>>,
queued: Option<(Instant, u64)>,
capture: Option<crate::capture::Capture>,
runner_exit: Option<i32>,
announced: bool,
}
const ANNOUNCE_SECS: u64 = 5;
pub struct Stage {
slots: Mutex<Vec<Slot>>,
out: Mutex<usize>,
live: bool,
on_push: bool,
quiet: bool,
log_dir: Option<std::path::PathBuf>,
named: AtomicBool,
stop: AtomicBool,
}
thread_local! {
static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
}
impl Stage {
pub fn begin(settings: &crate::config::Settings, names: &[&str]) -> Arc<Stage> {
let now = Instant::now();
let stage = Arc::new(Stage {
slots: Mutex::new(
names
.iter()
.map(|n| Slot {
name: crate::ui::sanitize(
n.strip_prefix("pre-commit-")
.or_else(|| n.strip_prefix("pre-push-"))
.unwrap_or(n),
),
started: now,
last_output: now,
next_beat: HEARTBEAT_SECS,
buf: Vec::new(),
running: false,
done: false,
activity: None,
queued: None,
capture: None,
runner_exit: None,
announced: false,
})
.collect(),
),
out: Mutex::new(0),
live: enabled(settings) && watching(),
on_push: names.iter().any(|n| n.starts_with("pre-push-")),
quiet: quiet(settings),
named: AtomicBool::new(false),
log_dir: if quiet(settings) {
crate::capture::log_dir()
} else {
None
},
stop: AtomicBool::new(false),
});
if stage.live {
let weak = Arc::downgrade(&stage);
let own = settings.for_thread();
let _ = std::thread::Builder::new()
.name("amont-live".into())
.spawn(move || tick(own, weak));
} else if enabled(settings) || stage.quiet {
let weak = Arc::downgrade(&stage);
let own = settings.for_thread();
let _ = std::thread::Builder::new()
.name("amont-heartbeat".into())
.spawn(move || heartbeat(own, weak));
}
stage
}
pub fn enter(self: &Arc<Stage>, idx: usize) -> SinkGuard {
{
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(idx) {
slot.running = true;
slot.started = Instant::now();
slot.last_output = slot.started;
slot.next_beat = HEARTBEAT_SECS;
if self.quiet {
slot.capture = Some(crate::capture::Capture::open(
self.log_dir.clone(),
&slot.name,
));
}
}
}
SINK.with(|s| *s.borrow_mut() = Some((Arc::clone(self), idx)));
SinkGuard
}
pub fn queue(&self, idx: usize, since: Option<(Instant, u64)>) {
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(idx) {
slot.queued = since;
}
}
pub fn attach(
self: &Arc<Stage>,
idx: usize,
activity: Arc<crate::hooks::common::Activity>,
) -> AttachGuard {
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(idx) {
slot.activity = Some(activity);
}
AttachGuard {
stage: Arc::clone(self),
idx,
}
}
pub fn append_raw(&self, idx: usize, bytes: &[u8]) {
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(idx) {
if !slot.done {
match slot.capture.as_mut() {
Some(capture) => capture.take(bytes),
None => slot.buf.extend_from_slice(bytes),
}
slot.last_output = Instant::now();
}
}
}
pub fn named(&self) {
self.named.store(true, Ordering::Relaxed);
}
pub fn runner_exit(&self, idx: usize, code: Option<i32>) {
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(idx) {
slot.runner_exit = code;
}
}
fn append_line(&self, idx: usize, line: &str) {
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(idx) {
if !slot.done {
slot.buf.extend_from_slice(line.as_bytes());
slot.buf.push(b'\n');
slot.last_output = Instant::now();
}
}
}
pub fn finish(
&self,
settings: &crate::config::Settings,
idx: usize,
outcome: Option<crate::check::Outcome>,
) {
let block = {
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
let Some(slot) = slots.get_mut(idx) else {
return;
};
slot.done = true;
slot.running = false;
let said = std::mem::take(&mut slot.buf);
match slot.capture.take() {
None => QuietOut::plain(said),
Some(capture) => quiet_block(QuietEnd {
name: &slot.name,
secs: slot.started.elapsed().as_secs_f64(),
outcome,
said,
capture,
runner_exit: slot.runner_exit,
ci: in_ci(),
ok_line: self.named.load(Ordering::Relaxed),
}),
}
};
if block.is_empty() && !self.live {
return;
}
let mut drawn = self.out.lock().unwrap_or_else(|p| p.into_inner());
if !block.is_empty() {
if *drawn > 0 {
let mut err = std::io::stderr().lock();
let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
let _ = err.flush();
*drawn = 0;
}
let stdout = std::io::stdout();
let mut handle = stdout.lock();
block.write(&mut handle);
let _ = handle.flush();
}
self.repaint(settings, &mut drawn);
}
fn repaint(&self, settings: &crate::config::Settings, drawn: &mut usize) {
if !self.live {
return;
}
let entries: Vec<Row> = {
let slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
let now = Instant::now();
slots
.iter()
.filter(|s| s.running && !s.done)
.map(|s| row_of(s, now, now.duration_since(s.started).as_secs_f64()))
.collect()
};
let text = region(&entries, term_width(), budgets(settings));
let mut paint = String::new();
if *drawn > 0 {
paint.push_str(&format!("\x1b[{}A\x1b[J", *drawn));
}
paint.push_str(&text);
if paint.is_empty() {
return;
}
let mut err = std::io::stderr().lock();
let _ = err.write_all(paint.as_bytes());
let _ = err.flush();
*drawn = text.matches('\n').count();
}
}
impl Drop for Stage {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if !self.live {
return;
}
let drawn = self.out.get_mut().unwrap_or_else(|p| p.into_inner());
if *drawn > 0 {
let mut err = std::io::stderr().lock();
let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
let _ = err.flush();
*drawn = 0;
}
}
}
fn tick(settings: crate::config::Settings, weak: Weak<Stage>) {
loop {
std::thread::sleep(std::time::Duration::from_millis(80));
let Some(stage) = weak.upgrade() else { return };
if stage.stop.load(Ordering::Relaxed) {
return;
}
let mut drawn = stage.out.lock().unwrap_or_else(|p| p.into_inner());
stage.repaint(&settings, &mut drawn);
}
}
#[derive(Debug, Clone)]
pub struct Row {
pub name: String,
pub elapsed: f64,
pub quiet: f64,
pub still: f64,
pub cpu: RowCpu,
pub wait: Option<(crate::hooks::wait::WaitKind, f64)>,
pub load: Option<(u64, crate::load::Load, u32)>,
pub queued: Option<(f64, u64)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowCpu {
None,
NotSampled,
Unmeasured,
Busy(u32),
Idle,
}
fn row_of(s: &Slot, now: Instant, elapsed: f64) -> Row {
use crate::hooks::common::{CpuState, BUSY_MILLI_CORES};
let slot_quiet = now.duration_since(s.last_output).as_secs_f64();
let (quiet, still, cpu, wait, load) = match &s.activity {
None => (slot_quiet, slot_quiet, RowCpu::None, None, None),
Some(a) => {
let quiet = slot_quiet.min(a.quiet_for().as_secs_f64());
let still = quiet.min(a.still_for().as_secs_f64());
let cpu = match (a.cpu_state(), a.fresh_rate()) {
(CpuState::Off, _) => RowCpu::NotSampled,
(_, Some(r)) if r >= BUSY_MILLI_CORES => RowCpu::Busy(r),
(_, Some(_)) => RowCpu::Idle,
(_, None) => RowCpu::Unmeasured,
};
let wait = a.waiting().map(|(k, d)| (k, d.as_secs_f64()));
(quiet, still, cpu, wait, a.load_scale())
}
};
Row {
name: s.name.clone(),
elapsed,
quiet,
still,
cpu,
wait,
load,
queued: s
.queued
.map(|(since, n)| (now.duration_since(since).as_secs_f64(), n)),
}
}
impl Row {
fn applied_idle(&self, budgets: Budgets) -> u64 {
match self.load {
Some((applied, _, _)) if applied > 0 => applied,
_ => budgets.idle,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Budgets {
pub idle: u64,
pub ceiling: u64,
pub lock_wait: u64,
pub extended: u64,
}
fn budgets(settings: &crate::config::Settings) -> Budgets {
let idle = crate::hooks::common::idle_timeout(settings);
Budgets {
idle,
extended: idle.saturating_mul(crate::hooks::common::idle_load_scale(settings)),
ceiling: crate::hooks::common::check_timeout(settings),
lock_wait: match crate::hooks::common::lock_wait(settings) {
crate::hooks::common::LockWait::Secs(s) => s,
crate::hooks::common::LockWait::UntilCeiling => 0,
},
}
}
const QUIET_NOTE_SECS: f64 = 30.0;
const HEARTBEAT_SECS: u64 = 60;
fn elapsed_column(secs: f64) -> String {
if secs < 60.0 {
format!("{secs:>5.1}s")
} else {
format!("{:>6}", crate::hooks::common::human_secs(secs as u64))
}
}
fn region(entries: &[Row], width: usize, budgets: Budgets) -> String {
if entries.is_empty() {
return String::new();
}
let pad = entries
.iter()
.take(MAX_LINES)
.map(|r| r.name.chars().count())
.max()
.unwrap_or(0);
let mut out = String::new();
for row in entries.iter().take(MAX_LINES) {
let frame = FRAMES[((row.elapsed * 10.0) as usize) % FRAMES.len()];
let name = &row.name;
let mut line = format!("{frame} {name:<pad$} {}", elapsed_column(row.elapsed));
if let Some((secs, n)) = row.queued {
line.push_str(&format!(
" · queued {} (slots {n}/{n})",
crate::hooks::common::human_secs(secs as u64)
));
} else if let Some((kind, waited)) = row.wait {
line.push_str(&format!(
" · {} {}",
kind.short(),
crate::hooks::common::human_secs(waited as u64)
));
if budgets.lock_wait > 0 {
line.push_str(&format!(
"/{}",
crate::hooks::common::human_secs(budgets.lock_wait)
));
}
} else if row.quiet >= QUIET_NOTE_SECS {
let quiet = crate::hooks::common::human_secs(row.quiet as u64);
match row.cpu {
RowCpu::Busy(m) => line.push_str(&format!(
" · quiet {quiet} · {}",
crate::hooks::common::cores(m)
)),
_ if budgets.idle == 0 => line.push_str(&format!(" · quiet {quiet}")),
RowCpu::Unmeasured => line.push_str(&format!(
" · quiet {quiet}/{} (CPU unmeasured)",
crate::hooks::common::human_secs(budgets.extended.max(budgets.idle))
)),
RowCpu::Idle if row.quiet - row.still >= 1.0 => line.push_str(&format!(
" · quiet {quiet} · idle {}/{}",
crate::hooks::common::human_secs(row.still as u64),
crate::hooks::common::human_secs(row.applied_idle(budgets))
)),
_ => line.push_str(&format!(
" · quiet {quiet}/{}",
crate::hooks::common::human_secs(row.applied_idle(budgets))
)),
}
if let (Some((_, _, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
line.push_str(&format!(" (load {})", crate::load::factor_text(factor)));
}
}
if budgets.ceiling > 0 && row.elapsed >= 0.8 * budgets.ceiling as f64 {
line.push_str(&format!(
" · {}/{}",
crate::hooks::common::human_secs(row.elapsed as u64),
crate::hooks::common::human_secs(budgets.ceiling)
));
}
if line.chars().count() > width {
out.extend(line.chars().take(width));
} else {
out.push_str(&line);
}
out.push('\n');
}
if entries.len() > MAX_LINES {
out.push_str(&format!("… and {} more\n", entries.len() - MAX_LINES));
}
out
}
struct QuietEnd<'a> {
name: &'a str,
secs: f64,
outcome: Option<crate::check::Outcome>,
said: Vec<u8>,
capture: crate::capture::Capture,
runner_exit: Option<i32>,
ci: bool,
ok_line: bool,
}
fn in_ci() -> bool {
std::env::var("CI").is_ok_and(|v| !v.is_empty() && v != "false" && v != "0")
}
fn quiet_block(mut end: QuietEnd) -> QuietOut {
let stored = end.capture.finish();
let failed = is_failure(end.outcome);
let exit = match (end.outcome, failed) {
(None, _) => None,
(Some(_), true) => Some(end.runner_exit.filter(|c| *c != 0).unwrap_or(1)),
(Some(_), false) => Some(0),
};
end.capture.end_entry(exit, stored.path());
let passed = end.outcome == Some(crate::check::Outcome::Passed);
if end.capture.seen == 0 {
if let Some(p) = stored.path() {
let _ = std::fs::remove_file(p);
}
let mut head = end.said;
if failed && end.runner_exit.is_some() {
head.extend_from_slice(b"(no output)\n");
if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
head.extend_from_slice(
format!("{} failed, runner exit {code}\n", end.name).as_bytes(),
);
}
}
return QuietOut::plain(head);
}
if passed && !end.ok_line {
return QuietOut::plain(end.said);
}
let path = stored.path().map(|p| p.display().to_string());
if passed {
let mut out = format!("ok {} {:.1}s", end.name, end.secs);
if let (crate::capture::Stored::Saved(_), Some(p)) = (&stored, &path) {
out.push_str(&format!(" log={p}"));
}
out.push('\n');
if let Some(note) = stored.note() {
out.push_str(¬e);
out.push('\n');
}
return QuietOut::plain(out.into_bytes());
}
let excerpt = end.capture.acc.finish(path.as_deref().unwrap_or("-"));
let mut tail = String::new();
if failed {
if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
tail.push_str(&format!("{} failed, runner exit {code}\n", end.name));
}
}
if let Some(p) = &path {
tail.push_str(&format!("full log: {p}\n"));
}
if let Some(note) = stored.note() {
tail.push_str(¬e);
tail.push('\n');
}
match (&stored, end.ci && failed) {
(crate::capture::Stored::Saved(p), true) => QuietOut {
head: end.said,
stream: Some((p.clone(), excerpt)),
tail: tail.into_bytes(),
},
_ => {
let mut head = end.said;
head.extend_from_slice(excerpt.as_bytes());
QuietOut {
head,
stream: None,
tail: tail.into_bytes(),
}
}
}
}
struct QuietOut {
head: Vec<u8>,
stream: Option<(std::path::PathBuf, String)>,
tail: Vec<u8>,
}
impl QuietOut {
fn plain(head: Vec<u8>) -> QuietOut {
QuietOut {
head,
stream: None,
tail: Vec::new(),
}
}
fn is_empty(&self) -> bool {
self.head.is_empty() && self.stream.is_none() && self.tail.is_empty()
}
fn write(self, to: &mut impl Write) {
let _ = to.write_all(&self.head);
if let Some((path, excerpt)) = self.stream {
let copied = std::fs::File::open(&path).and_then(|mut f| std::io::copy(&mut f, to));
if let Err(e) = copied {
let _ = to.write_all(excerpt.as_bytes());
let _ = writeln!(to, "log not read: {e}");
}
}
let _ = to.write_all(&self.tail);
}
}
fn is_failure(outcome: Option<crate::check::Outcome>) -> bool {
use crate::check::Outcome;
match outcome {
None => true,
Some(Outcome::Failed) => true,
Some(
Outcome::Passed
| Outcome::Warned
| Outcome::Fixed
| Outcome::Unavailable
| Outcome::Inert,
) => false,
}
}
fn heartbeat(settings: crate::config::Settings, weak: Weak<Stage>) {
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
let Some(stage) = weak.upgrade() else { return };
if stage.stop.load(Ordering::Relaxed) {
return;
}
let mut announce: Vec<String> = Vec::new();
let due: Vec<(Row, bool)> = {
let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
let now = Instant::now();
let mut due = Vec::new();
for s in slots.iter_mut().filter(|s| s.running && !s.done) {
let elapsed = now.duration_since(s.started).as_secs();
if !s.announced && elapsed >= ANNOUNCE_SECS {
if let Some(c) = s.capture.as_ref().filter(|c| c.writing() && c.seen > 0) {
s.announced = true;
announce.push(format!("running {}, log {}\n", s.name, c.tmp.display()));
}
}
if elapsed >= s.next_beat {
let first = s.next_beat == HEARTBEAT_SECS;
s.next_beat += HEARTBEAT_SECS;
due.push((row_of(s, now, elapsed as f64), first));
}
}
due
};
if due.is_empty() && announce.is_empty() {
continue;
}
let text: String = announce.concat()
+ &due
.iter()
.map(|(row, first)| beat_line(row, *first, budgets(&settings), stage.on_push))
.collect::<String>();
let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
let mut err = std::io::stderr().lock();
let _ = err.write_all(text.as_bytes());
let _ = err.flush();
}
}
fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
use crate::hooks::common::human_secs;
let mut line = format!(
" … {} still running: {}, last output {} ago",
row.name,
human_secs(row.elapsed as u64),
human_secs(row.quiet as u64)
);
if let Some((secs, n)) = row.queued {
line.push_str(&format!(
", queued {} for a host slot (amont.hostSlots {n})",
human_secs(secs as u64)
));
} else if let Some((kind, waited)) = row.wait {
line.push_str(&format!(
", waiting for {} {}",
kind.describe(),
human_secs(waited as u64)
));
match budgets.lock_wait {
0 => line.push_str(" (amont.lockWait 0: until the ceiling)"),
s => line.push_str(&format!(" (amont.lockWait {})", human_secs(s))),
}
} else {
match row.cpu {
RowCpu::Busy(m) => line.push_str(&format!(", busy {}", crate::hooks::common::cores(m))),
RowCpu::Idle => line.push_str(&format!(", CPU idle {}", human_secs(row.still as u64))),
RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
", CPU unmeasured — extended budget {}",
human_secs(budgets.extended.max(budgets.idle))
)),
RowCpu::Unmeasured => line.push_str(", CPU unmeasured"),
RowCpu::None | RowCpu::NotSampled => {}
}
if let (Some((applied, load, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
line.push_str(&format!(
", budget {} (load avg {} on {} cores, {} — amont.idleLoadScale)",
human_secs(applied),
load.avg1_text(),
load.cores,
crate::load::factor_text(factor)
));
}
}
if first {
let idle = match row.applied_idle(budgets) {
0 => "off".to_string(),
s => human_secs(s),
};
let ceiling = match budgets.ceiling {
0 => "off".to_string(),
s => human_secs(s),
};
match row.cpu {
_ if row.wait.is_some() => line.push_str(&format!(
" (killed after {} in the wait, or {ceiling} in total — amont.lockWait / \
amont.timeout)",
match budgets.lock_wait {
0 => ceiling.clone(),
s => human_secs(s),
}
)),
RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
" (killed after {} with no output while its CPU is unmeasured, or {ceiling} \
in total — amont.idleTimeout × amont.idleLoadScale / amont.timeout)",
human_secs(budgets.extended.max(budgets.idle))
)),
RowCpu::Busy(_) | RowCpu::Idle if budgets.idle > 0 => line.push_str(&format!(
" (killed after {idle} with no output and under 0.1 core of CPU, or \
{ceiling} in total — amont.idleTimeout / amont.timeout)"
)),
_ => line.push_str(&format!(
" (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
)),
}
if row.cpu == RowCpu::NotSampled && budgets.idle > 0 {
line.push_str("; CPU not sampled here, silence alone counts");
}
if on_push {
line.push_str(concat!(
"\n git opened its connection to the remote before calling this",
"\n gate, and it stays idle until the gate finishes. A remote may",
"\n close it first — GitHub does — and the push then fails with",
"\n \"Connection reset by peer\", naming the network rather than the",
"\n wait. ssh keepalive does not prevent this.",
"\n Declaring this check at pre-commit moves it off the push path —",
"\n see \"Moving a gate entry earlier\" in the docs.",
));
}
}
line.push('\n');
line
}
pub fn term_width() -> usize {
std::env::var("COLUMNS")
.ok()
.and_then(|c| c.parse::<usize>().ok())
.filter(|w| *w >= 20)
.unwrap_or(80)
}
pub struct FinishOnDrop<'a> {
stage: &'a Stage,
idx: usize,
outcome: std::cell::Cell<Option<crate::check::Outcome>>,
settings: &'a crate::config::Settings,
}
impl<'a> FinishOnDrop<'a> {
pub fn new(
settings: &'a crate::config::Settings,
stage: &'a Stage,
idx: usize,
) -> FinishOnDrop<'a> {
FinishOnDrop {
stage,
idx,
outcome: std::cell::Cell::new(None),
settings,
}
}
pub fn outcome(&self, outcome: crate::check::Outcome) {
self.outcome.set(Some(outcome));
}
}
impl Drop for FinishOnDrop<'_> {
fn drop(&mut self) {
self.stage
.finish(self.settings, self.idx, self.outcome.get());
}
}
pub struct SinkGuard;
impl Drop for SinkGuard {
fn drop(&mut self) {
SINK.with(|s| *s.borrow_mut() = None);
}
}
pub struct AttachGuard {
stage: Arc<Stage>,
idx: usize,
}
impl Drop for AttachGuard {
fn drop(&mut self) {
let mut slots = self.stage.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(self.idx) {
slot.activity = None;
}
}
}
pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
SINK.with(|s| s.borrow().clone())
}
pub fn say(line: &str) {
let routed = SINK.with(|s| {
s.borrow().as_ref().map(|(stage, idx)| {
stage.append_line(*idx, line);
})
});
if routed.is_none() {
println!("{line}");
}
}
#[macro_export]
macro_rules! say {
($($arg:tt)*) => {
$crate::live::say(&format!($($arg)*))
};
}
pub fn quiet(settings: &crate::config::Settings) -> bool {
*settings.quiet.get_or_init(|| {
decide(
crate::config::enumerated_or(settings, "amont.quiet", QUIET_VALUES, "auto"),
watching(),
)
})
}
pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
fn decide(setting: &str, watching: bool) -> bool {
match setting {
"always" => true,
"auto" => !watching,
_ => false,
}
}
pub fn enabled(settings: &crate::config::Settings) -> bool {
*settings
.progress
.get_or_init(|| crate::config::boolean_or(settings, "amont.progress", true))
}
pub fn watching() -> bool {
static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*WATCHING.get_or_init(|| {
if !std::io::stderr().is_terminal() {
return false;
}
match std::env::var("TERM") {
Ok(term) => term != "dumb",
Err(_) => !cfg!(windows),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_settings() -> crate::config::Settings {
crate::config::Settings::default()
}
#[test]
fn quiet_asks_who_is_reading() {
assert!(!decide("never", true));
assert!(!decide("never", false));
assert!(always_and_auto_agree_at_a_terminal());
assert!(decide("auto", false), "captured: nobody is watching");
assert!(decide("always", true));
assert!(decide("always", false));
assert!(!decide("shhh", false));
}
fn always_and_auto_agree_at_a_terminal() -> bool {
!decide("auto", true) && decide("always", true)
}
#[test]
fn slots_do_not_share_a_buffer() {
let stage = Stage::begin(&test_settings(), &["a", "b"]);
std::thread::scope(|scope| {
for idx in 0..2 {
let stage = Arc::clone(&stage);
scope.spawn(move || {
let _guard = stage.enter(idx);
for i in 0..50 {
say(&format!("check-{idx} line-{i}"));
std::thread::yield_now();
}
});
}
});
let slots = stage.slots.lock().unwrap();
for idx in 0..2 {
let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
assert_eq!(text.lines().count(), 50);
assert!(
text.lines()
.all(|l| l.starts_with(&format!("check-{idx} "))),
"a foreign line landed in slot {idx}"
);
}
}
#[test]
fn no_sink_means_no_capture() {
let stage = Stage::begin(&test_settings(), &["a"]);
say("goes to stdout, not to a slot");
let slots = stage.slots.lock().unwrap();
assert!(slots[0].buf.is_empty());
}
#[test]
fn a_finished_slot_takes_no_more_writes() {
let stage = Stage::begin(&test_settings(), &["a"]);
stage.append_raw(0, b"before\n");
stage.finish(&test_settings(), 0, None);
stage.append_raw(0, b"after\n");
let slots = stage.slots.lock().unwrap();
assert!(slots[0].buf.is_empty(), "a write landed after finish");
}
#[test]
fn a_slot_name_is_sanitised_at_begin() {
let stage = Stage::begin(&test_settings(), &["evil\u{1b}[2Jname\rhere"]);
let slots = stage.slots.lock().unwrap();
assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
}
#[test]
fn a_slot_name_drops_the_stage_prefix() {
let stage = Stage::begin(
&test_settings(),
&["pre-commit-clippy", "pre-push-run-tests", "bare"],
);
let slots = stage.slots.lock().unwrap();
assert_eq!(slots[0].name, "clippy");
assert_eq!(slots[1].name, "run-tests");
assert_eq!(slots[2].name, "bare");
}
fn row(name: &str, elapsed: f64) -> Row {
Row {
name: name.into(),
elapsed,
quiet: 0.0,
still: 0.0,
cpu: RowCpu::None,
wait: None,
load: None,
queued: None,
}
}
#[test]
fn a_queued_check_says_it_waits_for_a_host_slot() {
let mut r = row("pre-push-run-tests-js", 42.0);
r.queued = Some((42.0, 2));
let text = region(&[r.clone()], 80, B);
assert!(text.contains("· queued 42s (slots 2/2)"), "{text:?}");
assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
r.elapsed = 60.0;
r.quiet = 60.0;
r.queued = Some((60.0, 2));
let beat = beat_line(&r, false, B, false);
assert_eq!(
beat,
" … pre-push-run-tests-js still running: 1m00s, last output 1m00s ago, \
queued 1m00s for a host slot (amont.hostSlots 2)\n"
);
}
#[test]
fn a_load_stretched_budget_is_shown_with_its_load() {
let mut r = row("pre-push-run-tests-js", 240.0);
r.quiet = 130.0;
r.still = 130.0;
r.cpu = RowCpu::Idle;
r.load = Some((
468,
crate::load::Load {
avg1_milli: 31_200,
cores: 8,
},
3900,
));
let text = region(&[r.clone()], 80, B);
assert!(text.contains("· quiet 2m10s/7m48s (load ×3.9)"), "{text:?}");
assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
let beat = beat_line(&r, false, B, false);
assert!(
beat.ends_with(
", CPU idle 2m10s, budget 7m48s (load avg 31.2 on 8 cores, ×3.9 — amont.idleLoadScale)\n"
),
"{beat:?}"
);
let first = flat(&beat_line(&r, true, B, false));
assert!(
first.contains("killed after 7m48s with no output and under 0.1 core of CPU"),
"{first:?}"
);
}
const B: Budgets = Budgets {
idle: 120,
ceiling: 3600,
lock_wait: 600,
extended: 480,
};
#[test]
fn an_unmeasured_check_is_shown_against_the_extended_budget() {
let mut r = row("pre-push-run-tests-js", 240.0);
r.quiet = 130.0;
r.still = 130.0;
r.cpu = RowCpu::Unmeasured;
let text = region(&[r.clone()], 80, B);
assert!(
text.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
"{text:?}"
);
assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
let beat = beat_line(&r, false, B, false);
assert!(
beat.ends_with(", CPU unmeasured — extended budget 8m00s\n"),
"{beat:?}"
);
let first = flat(&beat_line(&r, true, B, false));
assert!(
first.contains(
"killed after 8m00s with no output while its CPU is unmeasured, or 1h00m in total"
),
"{first:?}"
);
assert!(first.contains("amont.idleLoadScale"), "{first:?}");
}
#[test]
fn a_declared_wait_is_shown_against_its_own_budget() {
use crate::hooks::wait::{CargoLockWhat, WaitKind};
let mut r = row("pre-push-run-tests-js", 240.0);
r.quiet = 130.0;
r.still = 0.0;
r.cpu = RowCpu::Unmeasured;
r.wait = Some((WaitKind::CargoLock(CargoLockWhat::BuildDirectory), 90.0));
let text = region(&[r.clone()], 80, B);
assert!(text.contains("· cargo lock 1m30s/10m00s"), "{text:?}");
assert!(!text.contains("quiet"), "no silence countdown: {text:?}");
assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
let until = Budgets { lock_wait: 0, ..B };
let text = region(&[r.clone()], 80, until);
assert!(
text.contains("· cargo lock 1m30s") && !text.contains("1m30s/"),
"{text:?}"
);
let beat = beat_line(&r, false, B, false);
assert_eq!(
beat,
" … pre-push-run-tests-js still running: 4m00s, last output 2m10s ago, \
waiting for the cargo lock on the build directory 1m30s (amont.lockWait 10m00s)\n"
);
let beat = beat_line(&r, false, until, false);
assert!(
beat.contains("(amont.lockWait 0: until the ceiling)"),
"{beat:?}"
);
let first = flat(&beat_line(&r, true, B, false));
assert!(
first.contains("(killed after 10m00s in the wait, or 1h00m in total — amont.lockWait"),
"{first:?}"
);
assert!(!first.contains("unmeasured"), "{first:?}");
}
#[test]
fn frames_advance_with_time() {
let a = region(&[row("clippy", 0.0)], 80, B);
let b = region(&[row("clippy", 0.1)], 80, B);
let c = region(&[row("clippy", 1.0)], 80, B);
assert_ne!(a.chars().next(), b.chars().next());
assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
}
#[test]
fn region_lines_align() {
let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert_eq!(widths[0], widths[1], "{text:?}");
let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert_eq!(widths[0], widths[1], "{text:?}");
assert!(text.contains("8m12s"), "{text:?}");
}
#[test]
fn region_caps_and_counts_the_rest() {
let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
let text = region(&entries, 80, B);
assert_eq!(text.lines().count(), MAX_LINES + 1);
assert!(text.ends_with("… and 1 more\n"), "{text:?}");
}
#[test]
fn region_respects_width() {
let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
}
#[test]
fn an_empty_region_is_empty() {
assert_eq!(region(&[], 80, B), "");
}
#[test]
fn a_quiet_check_shows_its_silence_against_the_budget() {
let mut r = row("cargo-test", 300.0);
r.quiet = 5.0;
assert!(!region(&[r.clone()], 80, B).contains("quiet"));
r.quiet = 45.0;
let text = region(&[r.clone()], 80, B);
assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
let off = Budgets { idle: 0, ..B };
let text = region(&[r], 80, off);
assert!(
text.contains("quiet 45s") && !text.contains('/'),
"{text:?}"
);
}
#[test]
fn a_quiet_busy_check_shows_cores_and_an_idle_one_counts_down_the_still_time() {
let mut r = row("vitest-workspace", 240.0);
r.quiet = 130.0;
r.still = 130.0;
r.cpu = RowCpu::Busy(3900);
let busy = region(&[r.clone()], 80, B);
assert!(busy.contains("· quiet 2m10s · ~3.9 cores"), "{busy:?}");
assert!(
!busy.contains("/2m00s"),
"no countdown while busy: {busy:?}"
);
r.cpu = RowCpu::Idle;
r.still = 40.0;
let idle = region(&[r.clone()], 80, B);
assert!(idle.contains("· quiet 2m10s · idle 40s/2m00s"), "{idle:?}");
r.cpu = RowCpu::Unmeasured;
r.still = 130.0;
let plain = region(&[r], 80, B);
assert!(
plain.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
"{plain:?}"
);
for text in [busy, idle, plain] {
assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
}
}
#[test]
fn a_heartbeat_appends_the_cpu_state_after_an_unchanged_prefix() {
let mut r = row("vitest", 240.0);
r.quiet = 130.0;
r.still = 40.0;
let prefix = " … vitest still running: 4m00s, last output 2m10s ago";
for (cpu, suffix) in [
(RowCpu::Busy(3900), ", busy ~3.9 cores\n"),
(RowCpu::Idle, ", CPU idle 40s\n"),
(
RowCpu::Unmeasured,
", CPU unmeasured — extended budget 8m00s\n",
),
(RowCpu::None, "\n"),
(RowCpu::NotSampled, "\n"),
] {
r.cpu = cpu;
assert_eq!(beat_line(&r, false, B, false), format!("{prefix}{suffix}"));
}
}
#[test]
fn the_first_beat_states_the_rule_in_force() {
let mut r = row("vitest", 60.0);
r.cpu = RowCpu::Idle;
let sampled = beat_line(&r, true, B, false);
assert!(
flat(&sampled).contains(
"killed after 2m00s with no output and under 0.1 core of CPU, or 1h00m in total"
),
"{sampled:?}"
);
r.cpu = RowCpu::NotSampled;
let not = beat_line(&r, true, B, false);
assert!(
not.contains("2m00s of silence or 1h00m in total"),
"{not:?}"
);
assert!(
not.contains("CPU not sampled here, silence alone counts"),
"{not:?}"
);
}
#[test]
fn the_ceiling_shows_only_when_it_is_near() {
assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
let text = region(&[row("cargo-test", 3000.0)], 80, B);
assert!(text.contains("50m00s/1h00m"), "{text:?}");
let off = Budgets { ceiling: 0, ..B };
assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
}
#[test]
fn a_heartbeat_names_the_budgets_once() {
let mut r = row("cargo-test", 60.0);
r.quiet = 2.0;
let first = beat_line(&r, true, B, false);
assert!(
first.contains("cargo-test still running: 1m00s"),
"{first:?}"
);
assert!(first.contains("last output 2s ago"), "{first:?}");
assert!(
first.contains("2m00s of silence or 1h00m in total"),
"{first:?}"
);
assert!(first.contains("amont.idleTimeout"), "{first:?}");
let later = beat_line(&r, false, B, false);
assert!(!later.contains("amont.idleTimeout"), "{later:?}");
let off = beat_line(
&r,
true,
Budgets {
idle: 0,
ceiling: 0,
lock_wait: 0,
extended: 0,
},
false,
);
assert!(off.contains("off of silence or off in total"), "{off:?}");
}
fn flat(line: &str) -> String {
line.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn a_long_push_gate_is_told_what_it_is_sitting_on() {
let r = row("cargo-test", 60.0);
let pushing = flat(&beat_line(&r, true, B, true));
assert!(
pushing.contains("A remote may close it first"),
"{pushing:?}"
);
assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
assert!(
pushing.contains("Moving a gate entry earlier"),
"{pushing:?}"
);
let later = flat(&beat_line(&r, false, B, true));
assert!(!later.contains("close it first"), "{later:?}");
let committing = flat(&beat_line(&r, true, B, false));
assert!(!committing.contains("close it first"), "{committing:?}");
}
#[test]
fn the_push_note_does_not_recommend_ssh_keepalive() {
let r = row("cargo-test", 60.0);
let pushing = flat(&beat_line(&r, true, B, true));
assert!(
!pushing.contains("ServerAlive"),
"keepalive was already on when this failed; recommending it \
would be useless advice: {pushing:?}"
);
assert!(
pushing.contains("ssh keepalive does not prevent this"),
"say so, rather than leaving the reader to try it: {pushing:?}"
);
}
}