monochromatic-git-policy-cli 0.6.0

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! What:
//!  Starting one package-manager program for a worktree repair: finding it on a search path, running it in a directory with
//!  its output captured and a quiet window, and saying how it ended.
//! Why:
//!  The repairs of a new worktree (`package_repair.rs`) start tools the repository uses (pnpm, Cargo, mpm-shadow). Each one
//!  must end in a plain verdict the add can word for the person: the program is missing, it failed with a reason, it showed
//!  no sign of work for its window, or it worked. A tool that works is never ended for being slow (owner, 2026-11-03:
//!  "Install online during the add"; pick 9 of `doc/planning/cli-git-shipped-limits-audit.md`, the coordinator's under the
//!  owner's delegation and open to the owner's veto), with the signs of work the push reads (`push_progress.rs`). Nothing here decides which tool runs or what its output means.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const program = findProgram('pnpm', process.env.PATH);
//! const run = await runTool(program, { arguments: ['install'], directory, set: [] }, quietWindow);
//! ```

/// The patience of a run that ends on a stall.
use crate::bounded_push::Patience;
/// The clock that reads the signs of work of a process tree.
use crate::push_progress::Clock;
/// The longest quiet gap of each tool, kept for the timing records.
use crate::output_capture::Capture;
use crate::quiet_ledger;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

/// What:
///  One program start: its arguments, the directory it runs in, and the variables added to its environment.
/// Why:
///   The program path is found separately, so a test can name a fixture and a person's search path is read once.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ToolCommand = { arguments: string[]; directory: string; set: [string, string][] };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolCommand {
    /// The arguments after the program name.
    pub arguments: Vec<OsString>,
    /// The working directory.
    pub directory: PathBuf,
    /// Variables added to the inherited environment.
    pub set: Vec<(OsString, OsString)>,
}

/// What:
///  How long a started program may stay silent and how long it may run in all.
/// Why:
///   Two bounds travel together: the quiet window ends a program that shows nothing, and the ceiling ends one that keeps showing
///  something (owner, 2026-11-04, "Silence plus a ceiling (Recommended)").
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ToolLimits = { window: number; ceiling: number };
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ToolLimits {
    /// How long the program may show no sign of work.
    pub window: Duration,
    /// The longest the program runs, whatever it shows.
    pub ceiling: Duration,
}

/// What:
///  How a started program ended.
/// Why:
///   The repair words each case differently, so they stay apart.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ToolEnd = { kind: 'succeeded' } | { kind: 'failed'; reason: string } | { kind: 'timed-out' } | { kind: 'ceiling'; afterMs: number };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ToolEnd {
    /// Exit status zero.
    Succeeded,
    /// The program did not start, or ended with a failure status; the text names the status and the tail of its output.
    Failed(String),
    /// The program showed no sign of work for its window, and was stopped.
    TimedOut,
    /// The program ran as long as the ceiling allows, whatever it showed, and was stopped with its whole process group.
    CeilingReached(Duration),
}

/// What:
///  The end of one run and how long it took.
/// Why:
///   The time is reported to the person and measured by the docs.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ToolRun = { end: ToolEnd; millis: number; stdout: Uint8Array };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolRun {
    /// How the program ended.
    pub end: ToolEnd,
    /// Wall time from start to end, in milliseconds.
    pub millis: u128,
    /// What the program wrote to its standard output; empty when it did not run to its end.
    pub stdout: Vec<u8>,
    /// What was left out of the output or could not be read, in words for the person. Output with a note is not the whole output:
    ///  a parser of `stdout` must not take it for a complete result.
    pub notes: Vec<String>,
}

/// The most bytes of a program's output kept for its failure text.
///  Exempt from a citation under `LQM` (class: display cap): it limits how much text a message, log field or record
///  names, and no command result or duration depends on it. Classed once in `doc/planning/cli-git-shipped-limits-
///  audit.md`, "Exempt limits"; the coordinator's pick under the owner's delegation, open to the owner's veto.
const OUTPUT_TAIL_BYTES: usize = 600;

/// How often a running program is looked at.
const POLL_INTERVAL: Duration = Duration::from_millis(10);

/// What:
///  The first executable file named `name` in a search path.
/// Why:
///   The program is the one the person's environment resolves; a missing one is a verdict, not a failure to start.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function findProgram(name: string, searchPath?: string): string | undefined;
/// ```
pub fn find_program(name: &str, search_path: Option<&OsStr>) -> Option<PathBuf> {
    use std::os::unix::fs::PermissionsExt;
    let search: &OsStr = search_path?;
    for directory in std::env::split_paths(search) {
        // An empty entry means the current directory in a shell; a repair never runs a program found there.
        if directory.as_os_str().is_empty() {
            continue;
        }
        let candidate: PathBuf = directory.join(name);
        let runnable: bool = match std::fs::metadata(&candidate) {
            Ok(found) => found.is_file() && found.permissions().mode() & 0o111 != 0,
            Err(_) => false,
        };
        if runnable {
            return Some(candidate);
        }
    }
    return None;
}

/// What:
///  The last bytes of what a program printed, as text, with the cut ragged edge dropped.
/// Why:
///   A failure is worded with the end of the output, where tools print their reason; the start is progress noise.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function tailOf(bytes: Uint8Array): string;
/// ```
fn tail_of(bytes: &[u8]) -> String {
    let cut: usize = bytes.len().saturating_sub(OUTPUT_TAIL_BYTES);
    let text: String = String::from_utf8_lossy(&bytes[cut..]).into_owned();
    // Lines that only say a tool waited for another process say nothing about why it failed.
    let mut informative_lines: Vec<&str> = Vec::new();
    for line in text.lines() {
        if !line.trim_start().starts_with("Blocking waiting for file lock") {
            informative_lines.push(line);
        }
    }
    let informative: String = informative_lines.join("\n");
    let trimmed: &str = informative.trim();
    if cut == 0 {
        return trimmed.to_owned();
    }
    let whole_lines: &str = match trimmed.split_once('\n') {
        Some((_ragged, rest)) => rest,
        None => trimmed,
    };
    return whole_lines.trim().to_owned();
}

/// What:
///  How a finished program's status is worded.
/// Why:
///   A signal has no code, and the person should see which of the two ended the program.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function describeStatus(status: ExitStatus): string;
/// ```
fn describe_status(status: std::process::ExitStatus) -> String {
    use std::os::unix::process::ExitStatusExt;
    return match (status.code(), status.signal()) {
        (Some(code), _) => format!("exit status {code}"),
        (None, Some(signal)) => format!("stopped by signal {signal}"),
        (None, None) => String::from("ended without a status"),
    };
}

/// What:
///  Run a program to its end, until it has shown no sign of work for the window, or until it has run as long as the ceiling.
/// Why:
///   The add must not wait for a tool that does nothing, and must keep the tail of its output for the log. A tool that works is
///  waited for until the ceiling (owner, 2026-11-04, "Silence plus a ceiling (Recommended)"; `wait_ceilings.rs`,
///  `REPAIR_TOOL_CEILING`), where it is ended like a silent one. The program leads a process group of its own, so the stop reaches the children it
///  started too. Where the system counts nothing (no `/proc/<pid>/io`) the quiet window never ends the tool, and only the
///  ceiling does. The longest quiet gap of the run is kept in the ledger under the program's name and first argument. The output
///  goes to anonymous files, so the ceiling covers the draining of it too: nothing waits for a stream to close, a helper that holds
///  an inherited stream after the tool has exited holds nothing up, and the stop (the group, while the leader is not yet reaped,
///  so the group number is still the leader's) never has to wait for a helper that left the group. A helper still running when the
///  leader has exited successfully is left alone, because a tool may start a helper on purpose; at the ceiling or the window the
///  whole group is stopped.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function runTool(program: string, command: ToolCommand, limits: { windowMs: number; ceilingMs: number }): Promise<ToolRun>;
/// ```
pub fn run_tool(program: &Path, command: &ToolCommand, limits: ToolLimits) -> ToolRun {
    return run_tool_reaping(program, command, limits, &reap_within_stop_grace);
}

/// What:
///  Reap a stopped leader within the push stop grace; whether it was reaped.
/// Why:
///   `run_tool` stops a leader the usual way, and a named function stands where a closure that ignored the captured
///  output stood.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const reapWithinStopGrace = (leader: Child, _captured: Capture) => reapWithin(leader, STOP_GRACE);
/// ```
fn reap_within_stop_grace(leader: Child, _captured: &Capture) -> bool {
    return reap_within(leader, crate::bounded_push::STOP_GRACE);
}

/// The run of a tool that never started because the files for its output could not be made.
fn no_place_for_output(program: &Path, started: Instant, error: &std::io::Error) -> ToolRun {
    return ToolRun { end: ToolEnd::Failed(format!("{} could not be started: no place for its output ({error})", program.display())), millis: started.elapsed().as_millis(), stdout: Vec::new(), notes: Vec::new() };
}

/// `run_tool` with the reaper of a stopped leader given: a control sees that the stop path reaps through it.
pub fn run_tool_reaping(program: &Path, command: &ToolCommand, limits: ToolLimits, reap: &dyn Fn(Child, &Capture) -> bool) -> ToolRun {
    use std::os::unix::process::CommandExt;
    let started: Instant = Instant::now();
    let mut starting: Command = Command::new(program);
    // The output goes to anonymous files and not to pipes: a helper that keeps an inherited stream open after the tool has exited, or
    // that left the process group while holding it, then holds nothing up, because no thread waits for a stream to close.
    let captured: Capture = match Capture::new() {
        Ok(files) => match files.stdio() {
            Ok((out, err)) => {
                starting.stdout(out).stderr(err);
                files
            }
            Err(error) => return no_place_for_output(program, started, &error),
        },
        Err(error) => return no_place_for_output(program, started, &error),
    };
    starting.args(&command.arguments).current_dir(&command.directory).stdin(Stdio::null()).process_group(0);
    for (name, value) in &command.set {
        starting.env(name, value);
    }
    let mut child: Child = match starting.spawn() {
        Ok(running) => running,
        Err(error) => return ToolRun { end: ToolEnd::Failed(format!("{} could not start: {error}", program.display())), millis: started.elapsed().as_millis(), stdout: Vec::new(), notes: Vec::new() },
    };
    let mut clock: Clock = Clock::start(child.id(), Patience::Progress { stall: limits.window }).without_wall_fallback();
    let mut at_ceiling: bool = false;
    let mut wait_failure: Option<std::io::Error> = None;
    let status: Option<std::process::ExitStatus> = loop {
        match wait_status(&mut child) {
            Ok(Some(done)) => break Some(done),
            // The ceiling ends the program whatever it shows (owner, 2026-11-04, "Silence plus a ceiling (Recommended)").
            Ok(None) if started.elapsed() >= limits.ceiling => {
                at_ceiling = true;
                break None;
            }
            Ok(None) if clock.check().is_some() => break None,
            Ok(None) => std::thread::sleep(POLL_INTERVAL),
            // A wait that fails is a stop like the others: the group is stopped, the pages are released and the leader is reaped below.
            Err(error) => {
                wait_failure = Some(error);
                break None;
            }
        }
    };
    // A clock that reads nothing (no `/proc`) has no quiet gap to keep: its figure would be the elapsed time.
    if clock.measures() {
        quiet_ledger::note(tool_step(program, command).as_str(), clock.longest_quiet(), status.is_none() && !at_ceiling && wait_failure.is_none());
    }
    let Some(finished) = status else {
        stop_group(&child);
        // The leader is reaped so it leaves no zombie, within a budget: a process in uninterruptible I/O ends only when the I/O does,
        // and a wait with no bound would keep the add for as long as it lasts. Nothing waits for the output, which is in files.
        // What the tool wrote is released first: nobody reads it, and a descendant that keeps the files open would keep the pages.
        captured.discard();
        let _reaped_in_time: bool = reap(child, &captured);
        let end: ToolEnd = match wait_failure {
            Some(error) => ToolEnd::Failed(format!("{} could not be waited for: {error}", program.display())),
            None if at_ceiling => ToolEnd::CeilingReached(limits.ceiling),
            None => ToolEnd::TimedOut,
        };
        return ToolRun { end, millis: started.elapsed().as_millis(), stdout: Vec::new(), notes: Vec::new() };
    };
    let shown: crate::output_capture::Captured = captured.snapshot(crate::output_capture::STEP_OUTPUT_LIMIT);
    // Nothing reads the files again, and a helper that still holds them would keep what they hold in memory.
    captured.discard();
    let (stdout, errors): (Vec<u8>, Vec<u8>) = (shown.stdout, shown.stderr);
    let millis: u128 = started.elapsed().as_millis();
    if finished.success() {
        return ToolRun { end: ToolEnd::Succeeded, millis, stdout, notes: shown.notes };
    }
    let combined: Vec<u8> = [stdout.as_slice(), errors.as_slice()].concat();
    let text: String = tail_of(combined.as_slice());
    // What was left out, or could not be read, is part of what the person is told: the text shows the tail only.
    let disclosed: String = if shown.notes.is_empty() { String::new() } else { format!(" ({})", shown.notes.join("; ")) };
    return ToolRun { end: ToolEnd::Failed(format!("{}: {text}{disclosed}", describe_status(finished))), millis, stdout: Vec::new(), notes: shown.notes };
}

/// `Child::try_wait`, and in a control an error once the file the seam names exists.
fn wait_status(child: &mut Child) -> std::io::Result<Option<std::process::ExitStatus>> {
    #[cfg(test)]
    if WAIT_FAILS_ONCE_THIS_FILE_EXISTS.with(|seam| { return seam.borrow().as_ref().is_some_and(|path| { return path.exists(); }); }) {
        return Err(std::io::Error::other("the wait failed"));
    }
    return child.try_wait();
}

#[cfg(test)]
thread_local! {
    /// A control makes the wait for the tool fail once the file it names exists, which the tool makes after it has written.
    pub(crate) static WAIT_FAILS_ONCE_THIS_FILE_EXISTS: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
}

/// What:
///  Reap `child` within `budget`; when it has not ended by then, hand it to a thread that reaps it whenever it does.
/// Why:
///   The stop is a signal, and a process whose kernel call cannot be interrupted ends only when the call does. The add returns
///  without it, and the thread keeps the leader from becoming a zombie for good. True when the leader ended within the budget.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const ended = await reapWithin(child, budgetMs); // false: a background task reaps it later
/// ```
fn reap_within(mut child: Child, budget: Duration) -> bool {
    let began: Instant = Instant::now();
    loop {
        match child.try_wait() {
            Ok(Some(_)) => return true,
            Ok(None) if began.elapsed() < budget => std::thread::sleep(POLL_INTERVAL),
            Ok(None) | Err(_) => break,
        }
    }
    // A thread that cannot be started leaves the leader to the system's init when this process ends; the add goes on either way.
    let _handed = std::thread::Builder::new().name(String::from("cli-git-leader-reaper")).spawn(move || {
        let _reaped = child.wait();
    });
    return false;
}

/// What:
///  The name a tool's quiet gap is kept under: the program's file name and its first argument, as in `pnpm install`.
/// Why:
///   Two uses of one program (`cargo metadata`, `cargo clean`) have different gaps, and the arguments after the first are
///  paths that would make a name for every worktree.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// [basename(program), args[0]].filter(Boolean).join(' ')
/// ```
fn tool_step(program: &Path, command: &ToolCommand) -> String {
    let name: String = match program.file_name() {
        Some(file) => file.to_string_lossy().into_owned(),
        None => String::new(),
    };
    return match command.arguments.first() {
        Some(first) => format!("{name} {}", first.to_string_lossy()),
        None => name,
    };
}

/// What:
///  Stop the child's whole process group.
/// Why:
///   pnpm and Cargo start helpers; stopping only the leader would leave them running against the worktree.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// process.kill(-child.pid, 'SIGKILL');
/// ```
fn stop_group(child: &Child) {
    let Ok(leader) = libc::pid_t::try_from(child.id()) else { return };
    // SAFETY: `kill` takes a process-group id and a signal number and touches no memory; the group was made by this process.
    let _stopped = unsafe { libc::kill(-leader, libc::SIGKILL) };
}

/// Command controls stay out of the release executable.
#[cfg(test)]
#[path = "package_repair_command_tests.rs"]
mod tests;