Skip to main content

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}