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