gitwig_core/process.rs
1//! Child-process construction that can never write to, or read from, the TUI.
2//!
3//! Gitwig draws on the terminal's alternate screen. Any child that inherits the
4//! parent's stdio can scribble over that screen (git's `error: cannot run gpg`
5//! on a signed commit did exactly this from the Graph tab) or block on a prompt
6//! the user cannot see. Every non-interactive child in the workspace is
7//! therefore built here, with all three stdio streams detached from the tty by
8//! default, and a clippy `disallowed-methods` rule fails the build on any bare
9//! `std::process::Command::new`.
10//!
11//! - [`git_command`]: hardened `git` with prompts disabled and stdio piped.
12//! - [`tool_command`]: any other helper (`gh`, `curl`, `pbpaste`, …) with stdio piped.
13//! - [`detached_command`]: fire-and-forget helpers (`open`, `xdg-open`) with stdio nulled.
14//! - [`spawn_pipe_reader`]: drains a pipe on a thread so a chatty child never deadlocks.
15
16use std::io::Read;
17use std::process::{Child, Command, Stdio};
18use std::thread::JoinHandle;
19
20/// `BatchMode=yes` is load-bearing: without it, ssh bypasses the captured
21/// stdio pipes and prompts for passphrases or confirmations directly on
22/// `/dev/tty`, painting raw text over the TUI's alternate screen. With it,
23/// ssh fails immediately and the error is captured and reported normally.
24pub fn ssh_command_val() -> &'static str {
25 if std::env::var("GITWIG_SSH_STRICT").map(|v| v == "1").unwrap_or(false) {
26 "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes"
27 } else {
28 "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
29 }
30}
31
32/// Builds a `git` command that can neither prompt nor print on the terminal.
33///
34/// - `GIT_TERMINAL_PROMPT=0` stops git asking for a username/password on the tty.
35/// - `GIT_SSH_COMMAND` pins host-key handling so ssh never asks to confirm a
36/// fingerprint (see [`ssh_command_val`]).
37/// - `GIT_ASKPASS`/`SSH_ASKPASS` are neutralised so no GUI or console credential
38/// helper can be spawned behind the alternate screen.
39/// - The protocol allowlist blocks `ext::`-style transports from a hostile
40/// `.gitmodules` or remote URL.
41/// - stdin is `/dev/null`, stdout and stderr are pipes. A caller that only
42/// reads stdout must drain stderr too (see [`spawn_pipe_reader`]), or use
43/// `output()` / `wait_with_output()` which drain both.
44pub fn git_command() -> Command {
45 let mut cmd = tool_command("git");
46 cmd.env("GIT_TERMINAL_PROMPT", "0");
47 cmd.env("GIT_SSH_COMMAND", ssh_command_val());
48 cmd.env("GIT_ALLOW_PROTOCOL", "https:ssh:git:file");
49 cmd.env("GIT_PROTOCOL_FROM_USER", "0");
50 // An askpass helper would pop a prompt *outside* the alternate screen.
51 cmd.env("GIT_ASKPASS", "");
52 cmd.env("SSH_ASKPASS", "");
53 cmd.env("GCM_INTERACTIVE", "Never");
54 cmd
55}
56
57/// Builds a non-git helper command with every stdio stream detached from the tty.
58///
59/// stdin is `/dev/null` so nothing can steal keystrokes; stdout and stderr are
60/// pipes so nothing can reach the screen. Callers that need to feed stdin
61/// override it with `Stdio::piped()` after construction.
62#[allow(clippy::disallowed_methods)]
63pub fn tool_command(program: &str) -> Command {
64 let mut cmd = Command::new(program);
65 cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
66 cmd
67}
68
69/// Builds a fire-and-forget helper whose output nobody will read.
70///
71/// All three streams go to `/dev/null`, so `status()` can be called without a
72/// reader and the child can never block on a full pipe or touch the screen.
73#[allow(clippy::disallowed_methods)]
74pub fn detached_command(program: &str) -> Command {
75 let mut cmd = Command::new(program);
76 cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
77 cmd
78}
79
80/// Takes the child's stderr pipe and drains it on a background thread.
81///
82/// A child whose stderr is piped but never read blocks once the pipe buffer
83/// fills, which turns a noisy `git log` into a hang. Join the handle after the
84/// child exits to get everything it wrote.
85pub fn spawn_pipe_reader(child: &mut Child) -> JoinHandle<Vec<u8>> {
86 let mut pipe = child.stderr.take();
87 std::thread::spawn(move || {
88 let mut buf = Vec::new();
89 if let Some(p) = pipe.as_mut() {
90 let _ = p.read_to_end(&mut buf);
91 }
92 buf
93 })
94}
95
96/// Turns a child's captured stderr into a one-line message fit for the status bar.
97///
98/// Git repeats `error: cannot run gpg: No such file or directory` once per
99/// signed commit; the user only needs to see it once, with its `error: ` prefix
100/// stripped. Returns `None` when stderr was empty or whitespace.
101pub fn summarize_stderr(stderr: &[u8]) -> Option<String> {
102 let text = String::from_utf8_lossy(stderr);
103 let mut seen: Vec<&str> = Vec::new();
104 for line in text.lines() {
105 let line = line.trim();
106 let line =
107 line.strip_prefix("error: ").or_else(|| line.strip_prefix("fatal: ")).unwrap_or(line);
108 if !line.is_empty() && !seen.contains(&line) {
109 seen.push(line);
110 }
111 }
112 match seen.as_slice() {
113 [] => None,
114 [only] => Some((*only).to_string()),
115 [first, rest @ ..] => Some(format!("{} (+{} more)", first, rest.len())),
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn git_command_detaches_every_stdio_stream() {
125 let mut child = git_command().arg("--version").spawn().expect("git must be installed");
126 assert!(child.stdin.is_none(), "stdin must be /dev/null, not inherited");
127 assert!(child.stdout.is_some(), "stdout must be a pipe, not the terminal");
128 assert!(child.stderr.is_some(), "stderr must be a pipe, not the terminal");
129 let _ = child.wait();
130 }
131
132 #[test]
133 fn tool_command_detaches_every_stdio_stream() {
134 let mut child =
135 tool_command("git").arg("--version").spawn().expect("git must be installed");
136 assert!(child.stdin.is_none());
137 assert!(child.stdout.is_some());
138 assert!(child.stderr.is_some());
139 let _ = child.wait();
140 }
141
142 #[test]
143 fn detached_command_owns_no_pipes_and_can_use_status() {
144 let status =
145 detached_command("git").arg("--version").status().expect("git must be installed");
146 assert!(status.success());
147 }
148
149 #[test]
150 fn summarize_stderr_dedupes_and_strips_git_prefixes() {
151 let raw = b"error: cannot run gpg: No such file or directory\n\
152 error: cannot run gpg: No such file or directory\n\
153 fatal: something else\n";
154 assert_eq!(
155 summarize_stderr(raw).as_deref(),
156 Some("cannot run gpg: No such file or directory (+1 more)")
157 );
158 assert_eq!(summarize_stderr(b" \n"), None);
159 assert_eq!(summarize_stderr(b"error: just one\n").as_deref(), Some("just one"));
160 }
161}