git_policy_cli/repair/package_repair_command.rs
1//! What:
2//! Starting one package-manager program for a worktree repair: finding it on a search path, running it in a directory with
3//! its output captured and a quiet window, and saying how it ended.
4//! Why:
5//! The repairs of a new worktree (`package_repair.rs`) start tools the repository uses (pnpm, Cargo, mpm-shadow). Each one
6//! must end in a plain verdict the add can word for the person: the program is missing, it failed with a reason, it showed
7//! no sign of work for its window, or it worked. A tool that works is never ended for being slow (owner, 2026-11-03:
8//! "Install online during the add"; pick 9 of `doc/planning/cli-git-shipped-limits-audit.md`, the coordinator's under the
9//! 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.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const program = findProgram('pnpm', process.env.PATH);
14//! const run = await runTool(program, { arguments: ['install'], directory, set: [] }, quietWindow);
15//! ```
16
17/// The patience of a run that ends on a stall.
18use crate::bounded_push::Patience;
19/// The clock that reads the signs of work of a process tree.
20use crate::push_progress::Clock;
21/// The longest quiet gap of each tool, kept for the timing records.
22use crate::output_capture::Capture;
23use crate::quiet_ledger;
24use std::ffi::{OsStr, OsString};
25use std::path::{Path, PathBuf};
26use std::process::{Child, Command, Stdio};
27use std::time::{Duration, Instant};
28
29/// What:
30/// One program start: its arguments, the directory it runs in, and the variables added to its environment.
31/// Why:
32/// The program path is found separately, so a test can name a fixture and a person's search path is read once.
33///
34/// In TS you'd write (pseudocode):
35/// ```ts
36/// type ToolCommand = { arguments: string[]; directory: string; set: [string, string][] };
37/// ```
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct ToolCommand {
40 /// The arguments after the program name.
41 pub arguments: Vec<OsString>,
42 /// The working directory.
43 pub directory: PathBuf,
44 /// Variables added to the inherited environment.
45 pub set: Vec<(OsString, OsString)>,
46}
47
48/// What:
49/// How long a started program may stay silent and how long it may run in all.
50/// Why:
51/// Two bounds travel together: the quiet window ends a program that shows nothing, and the ceiling ends one that keeps showing
52/// something (owner, 2026-11-04, "Silence plus a ceiling (Recommended)").
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// type ToolLimits = { window: number; ceiling: number };
57/// ```
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub struct ToolLimits {
60 /// How long the program may show no sign of work.
61 pub window: Duration,
62 /// The longest the program runs, whatever it shows.
63 pub ceiling: Duration,
64}
65
66/// What:
67/// How a started program ended.
68/// Why:
69/// The repair words each case differently, so they stay apart.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// type ToolEnd = { kind: 'succeeded' } | { kind: 'failed'; reason: string } | { kind: 'timed-out' } | { kind: 'ceiling'; afterMs: number };
74/// ```
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub enum ToolEnd {
77 /// Exit status zero.
78 Succeeded,
79 /// The program did not start, or ended with a failure status; the text names the status and the tail of its output.
80 Failed(String),
81 /// The program showed no sign of work for its window, and was stopped.
82 TimedOut,
83 /// The program ran as long as the ceiling allows, whatever it showed, and was stopped with its whole process group.
84 CeilingReached(Duration),
85}
86
87/// What:
88/// The end of one run and how long it took.
89/// Why:
90/// The time is reported to the person and measured by the docs.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// type ToolRun = { end: ToolEnd; millis: number; stdout: Uint8Array };
95/// ```
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct ToolRun {
98 /// How the program ended.
99 pub end: ToolEnd,
100 /// Wall time from start to end, in milliseconds.
101 pub millis: u128,
102 /// What the program wrote to its standard output; empty when it did not run to its end.
103 pub stdout: Vec<u8>,
104}
105
106/// The most bytes of a program's output kept for its failure text.
107/// Exempt from a citation under `LQM` (class: display cap): it limits how much text a message, log field or record
108/// names, and no command result or duration depends on it. Classed once in `doc/planning/cli-git-shipped-limits-
109/// audit.md`, "Exempt limits"; the coordinator's pick under the owner's delegation, open to the owner's veto.
110const OUTPUT_TAIL_BYTES: usize = 600;
111
112/// How often a running program is looked at.
113const POLL_INTERVAL: Duration = Duration::from_millis(10);
114
115/// What:
116/// The first executable file named `name` in a search path.
117/// Why:
118/// The program is the one the person's environment resolves; a missing one is a verdict, not a failure to start.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// function findProgram(name: string, searchPath?: string): string | undefined;
123/// ```
124pub fn find_program(name: &str, search_path: Option<&OsStr>) -> Option<PathBuf> {
125 use std::os::unix::fs::PermissionsExt;
126 let search: &OsStr = search_path?;
127 for directory in std::env::split_paths(search) {
128 // An empty entry means the current directory in a shell; a repair never runs a program found there.
129 if directory.as_os_str().is_empty() {
130 continue;
131 }
132 let candidate: PathBuf = directory.join(name);
133 let runnable: bool = std::fs::metadata(&candidate).is_ok_and(|found: std::fs::Metadata| { return found.is_file() && found.permissions().mode() & 0o111 != 0; });
134 if runnable {
135 return Some(candidate);
136 }
137 }
138 return None;
139}
140
141/// What:
142/// The last bytes of what a program printed, as text, with the cut ragged edge dropped.
143/// Why:
144/// A failure is worded with the end of the output, where tools print their reason; the start is progress noise.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// function tailOf(bytes: Uint8Array): string;
149/// ```
150fn tail_of(bytes: &[u8]) -> String {
151 let cut: usize = bytes.len().saturating_sub(OUTPUT_TAIL_BYTES);
152 let text: String = String::from_utf8_lossy(&bytes[cut..]).into_owned();
153 // Lines that only say a tool waited for another process say nothing about why it failed.
154 let informative: String = text.lines().filter(|line: &&str| { return !line.trim_start().starts_with("Blocking waiting for file lock"); }).collect::<Vec<&str>>().join("\n");
155 let trimmed: &str = informative.trim();
156 if cut == 0 {
157 return trimmed.to_owned();
158 }
159 return trimmed.split_once('\n').map_or(trimmed, |(_ragged, rest): (&str, &str)| { return rest; }).trim().to_owned();
160}
161
162/// What:
163/// How a finished program's status is worded.
164/// Why:
165/// A signal has no code, and the person should see which of the two ended the program.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// function describeStatus(status: ExitStatus): string;
170/// ```
171fn describe_status(status: std::process::ExitStatus) -> String {
172 use std::os::unix::process::ExitStatusExt;
173 return match (status.code(), status.signal()) {
174 (Some(code), _) => format!("exit status {code}"),
175 (None, Some(signal)) => format!("stopped by signal {signal}"),
176 (None, None) => String::from("ended without a status"),
177 };
178}
179
180/// What:
181/// 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.
182/// Why:
183/// 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
184/// waited for until the ceiling (owner, 2026-11-04, "Silence plus a ceiling (Recommended)"; `wait_ceilings.rs`,
185/// `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
186/// started too. Where the system counts nothing (no `/proc/<pid>/io`) the quiet window never ends the tool, and only the
187/// ceiling does. The longest quiet gap of the run is kept in the ledger under the program's name and first argument. The output
188/// goes to anonymous files, so the ceiling covers the draining of it too: nothing waits for a stream to close, a helper that holds
189/// an inherited stream after the tool has exited holds nothing up, and the stop (the group, while the leader is not yet reaped,
190/// 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
191/// leader has exited successfully is left alone, because a tool may start a helper on purpose; at the ceiling or the window the
192/// whole group is stopped.
193///
194/// In TS you'd write (pseudocode):
195/// ```ts
196/// async function runTool(program: string, command: ToolCommand, limits: { windowMs: number; ceilingMs: number }): Promise<ToolRun>;
197/// ```
198pub fn run_tool(program: &Path, command: &ToolCommand, limits: ToolLimits) -> ToolRun {
199 use std::os::unix::process::CommandExt;
200 let started: Instant = Instant::now();
201 let mut starting: Command = Command::new(program);
202 // The output goes to anonymous files and not to pipes: a helper that keeps an inherited stream open after the tool has exited, or
203 // that left the process group while holding it, then holds nothing up, because no thread waits for a stream to close.
204 let captured: Capture = match Capture::new().and_then(|files| { return files.stdio().map(|streams| { return (files, streams); }); }) {
205 Ok((files, (out, err))) => {
206 starting.stdout(out).stderr(err);
207 files
208 }
209 Err(error) => 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() },
210 };
211 starting.args(&command.arguments).current_dir(&command.directory).stdin(Stdio::null()).process_group(0);
212 for (name, value) in &command.set {
213 starting.env(name, value);
214 }
215 let mut child: Child = match starting.spawn() {
216 Ok(running) => running,
217 Err(error) => return ToolRun { end: ToolEnd::Failed(format!("{} could not start: {error}", program.display())), millis: started.elapsed().as_millis(), stdout: Vec::new() },
218 };
219 let mut clock: Clock = Clock::start(child.id(), Patience::Progress { stall: limits.window }).without_wall_fallback();
220 let mut at_ceiling: bool = false;
221 let status: Option<std::process::ExitStatus> = loop {
222 match child.try_wait() {
223 Ok(Some(done)) => break Some(done),
224 // The ceiling ends the program whatever it shows (owner, 2026-11-04, "Silence plus a ceiling (Recommended)").
225 Ok(None) if started.elapsed() >= limits.ceiling => {
226 at_ceiling = true;
227 break None;
228 }
229 Ok(None) if clock.check().is_some() => break None,
230 Ok(None) => std::thread::sleep(POLL_INTERVAL),
231 Err(error) => return ToolRun { end: ToolEnd::Failed(format!("{} could not be waited for: {error}", program.display())), millis: started.elapsed().as_millis(), stdout: Vec::new() },
232 }
233 };
234 // A clock that reads nothing (no `/proc`) has no quiet gap to keep: its figure would be the elapsed time.
235 if clock.measures() {
236 quiet_ledger::note(tool_step(program, command).as_str(), clock.longest_quiet(), status.is_none() && !at_ceiling);
237 }
238 let Some(finished) = status else {
239 stop_group(&child);
240 // 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,
241 // 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.
242 let _reaped_in_time: bool = reap_within(child, crate::bounded_push::STOP_GRACE);
243 let end: ToolEnd = if at_ceiling { ToolEnd::CeilingReached(limits.ceiling) } else { ToolEnd::TimedOut };
244 return ToolRun { end, millis: started.elapsed().as_millis(), stdout: Vec::new() };
245 };
246 let shown: crate::output_capture::Captured = captured.snapshot(crate::output_capture::STEP_OUTPUT_LIMIT);
247 // Nothing reads the files again, and a helper that still holds them would keep what they hold in memory.
248 captured.discard();
249 let (stdout, errors): (Vec<u8>, Vec<u8>) = (shown.stdout, shown.stderr);
250 let millis: u128 = started.elapsed().as_millis();
251 if finished.success() {
252 return ToolRun { end: ToolEnd::Succeeded, millis, stdout };
253 }
254 let combined: Vec<u8> = [stdout.as_slice(), errors.as_slice()].concat();
255 let text: String = tail_of(combined.as_slice());
256 return ToolRun { end: ToolEnd::Failed(format!("{}: {text}", describe_status(finished))), millis, stdout: Vec::new() };
257}
258
259/// What:
260/// Reap `child` within `budget`; when it has not ended by then, hand it to a thread that reaps it whenever it does.
261/// Why:
262/// The stop is a signal, and a process whose kernel call cannot be interrupted ends only when the call does. The add returns
263/// without it, and the thread keeps the leader from becoming a zombie for good. True when the leader ended within the budget.
264///
265/// In TS you'd write (pseudocode):
266/// ```ts
267/// const ended = await reapWithin(child, budgetMs); // false: a background task reaps it later
268/// ```
269fn reap_within(mut child: Child, budget: Duration) -> bool {
270 let began: Instant = Instant::now();
271 loop {
272 match child.try_wait() {
273 Ok(Some(_)) => return true,
274 Ok(None) if began.elapsed() < budget => std::thread::sleep(POLL_INTERVAL),
275 Ok(None) | Err(_) => break,
276 }
277 }
278 // A thread that cannot be started leaves the leader to the system's init when this process ends; the add goes on either way.
279 let _handed = std::thread::Builder::new().name(String::from("cli-git-leader-reaper")).spawn(move || {
280 let _reaped = child.wait();
281 });
282 return false;
283}
284
285/// What:
286/// The name a tool's quiet gap is kept under: the program's file name and its first argument, as in `pnpm install`.
287/// Why:
288/// Two uses of one program (`cargo metadata`, `cargo clean`) have different gaps, and the arguments after the first are
289/// paths that would make a name for every worktree.
290///
291/// In TS you'd write (pseudocode):
292/// ```ts
293/// [basename(program), args[0]].filter(Boolean).join(' ')
294/// ```
295fn tool_step(program: &Path, command: &ToolCommand) -> String {
296 let name: String = program.file_name().map_or_else(String::new, |file| { return file.to_string_lossy().into_owned(); });
297 return match command.arguments.first() {
298 Some(first) => format!("{name} {}", first.to_string_lossy()),
299 None => name,
300 };
301}
302
303/// What:
304/// Stop the child's whole process group.
305/// Why:
306/// pnpm and Cargo start helpers; stopping only the leader would leave them running against the worktree.
307///
308/// In TS you'd write (pseudocode):
309/// ```ts
310/// process.kill(-child.pid, 'SIGKILL');
311/// ```
312fn stop_group(child: &Child) {
313 let Ok(leader) = libc::pid_t::try_from(child.id()) else { return };
314 // SAFETY: `kill` takes a process-group id and a signal number and touches no memory; the group was made by this process.
315 let _stopped = unsafe { libc::kill(-leader, libc::SIGKILL) };
316}
317
318/// Command controls stay out of the release executable.
319#[cfg(test)]
320#[path = "package_repair_command_tests.rs"]
321mod tests;