gwm/command_log.rs
1//! In-memory transcript of the external commands gwm runs (issue #226).
2//!
3//! gwm shells out to `gh` (GitHub status), bootstrap shell steps, and
4//! lifecycle hooks. The single-line statusbar action log (#217) shows only
5//! the most recent action and is ephemeral; this module keeps a bounded,
6//! scrollable history behind the lazygit-style Command Logs modal.
7//!
8//! ## Why a process-global ring
9//!
10//! The exec chokepoints live in library modules with no `App` handle
11//! (`github::run_gh_with` runs on a worker thread, #217), so the sink has
12//! to be reachable without threading a logger through every call site. A
13//! `LazyLock<Mutex<…>>` ring mirrors the existing static caches
14//! (`naming`'s compiled regexes, the recent-commits cache) and tolerates
15//! cross-thread writes from the off-thread GitHub fetch.
16//!
17//! The TUI never renders straight off this global: `App` takes a
18//! [`snapshot`] into `state::command_logs` so the modal renders from owned
19//! `App` state. That keeps the render path testable without the global and
20//! is the boundary the modal-render tests inject through.
21//!
22//! ## Scope (deliberately narrow for the first cut)
23//!
24//! Only the **captured-output** shell commands are logged: `gh`, bootstrap
25//! steps, hooks, and the user-triggered mutating git ops (`pull`, `push`,
26//! `sync`'s `fetch`/`rebase`/`merge`, and the `rename` steps — #290). The
27//! read-only sidebar previews (`worktree::run_git` for `git log` /
28//! `git status`) are *not* — they fire on every selection change and would
29//! bury the real operations in noise (the mutating sync steps go through the
30//! logged [`run_git_logged`](crate::worktree::run_git_logged) sibling
31//! instead). Interactive launchers (`.status()` / `.spawn()`, which inherit
32//! the terminal and have no captured output) and the libgit2 worktree ops
33//! (which run no subprocess) are out of scope here.
34
35use std::collections::VecDeque;
36use std::process::{Command, Output};
37use std::sync::{LazyLock, Mutex};
38use std::time::{Duration, Instant};
39
40/// Upper bound on retained entries. Old entries are evicted FIFO once the
41/// ring is full — a transcript, not an audit trail (the operation journal
42/// behind `gwm undo` / `gwm history` is the durable record).
43pub const MAX_ENTRIES: usize = 256;
44
45/// Cap on the captured output stored per entry. Keeps a chatty command
46/// (a verbose hook, a large `gh … --json`) from ballooning the in-memory
47/// log; the tail is kept since that is where errors surface.
48const MAX_OUTPUT_BYTES: usize = 8 * 1024;
49
50/// How a logged command finished.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum CommandStatus {
53 /// The process ran to completion. `Some(code)` is its exit status;
54 /// `None` means it was terminated by a signal (no code on Unix).
55 Exited(Option<i32>),
56 /// The process could not be spawned at all (binary missing, etc.).
57 Spawn,
58}
59
60impl CommandStatus {
61 /// `true` only for a clean `exit 0`. A signal death or spawn failure is
62 /// never a success.
63 pub fn is_success(&self) -> bool {
64 matches!(self, CommandStatus::Exited(Some(0)))
65 }
66}
67
68/// One executed command in the transcript: the resolved command line, how
69/// long it took, how it finished, and its captured (bounded) output.
70#[derive(Debug, Clone)]
71pub struct CommandLogEntry {
72 /// Human-readable resolved command line, e.g. `gh issue view 226 --json …`.
73 pub command: String,
74 /// Wall-clock duration of the call.
75 pub duration: Duration,
76 /// How the command finished.
77 pub status: CommandStatus,
78 /// Captured stdout (or stderr when stdout was empty), trimmed and
79 /// tail-bounded to [`MAX_OUTPUT_BYTES`]. Empty when nothing was captured.
80 pub output: String,
81}
82
83impl CommandLogEntry {
84 /// `true` when the command exited cleanly. Drives the green/red colour
85 /// of the status line in the modal.
86 pub fn is_success(&self) -> bool {
87 self.status.is_success()
88 }
89}
90
91/// Bounded FIFO ring of [`CommandLogEntry`]. Pure state — no global, no
92/// I/O — so its push/evict/snapshot contract is unit-testable in
93/// isolation.
94#[derive(Debug, Default)]
95pub struct CommandLog {
96 entries: VecDeque<CommandLogEntry>,
97}
98
99impl CommandLog {
100 /// An empty log.
101 pub fn new() -> Self {
102 Self::default()
103 }
104
105 /// Append `entry`, evicting the oldest when the ring is already at
106 /// [`MAX_ENTRIES`].
107 pub fn push(&mut self, entry: CommandLogEntry) {
108 if self.entries.len() == MAX_ENTRIES {
109 self.entries.pop_front();
110 }
111 self.entries.push_back(entry);
112 }
113
114 /// Number of retained entries.
115 pub fn len(&self) -> usize {
116 self.entries.len()
117 }
118
119 /// `true` when nothing has been logged yet.
120 pub fn is_empty(&self) -> bool {
121 self.entries.is_empty()
122 }
123
124 /// Oldest-first iterator over the retained entries.
125 pub fn iter(&self) -> impl Iterator<Item = &CommandLogEntry> {
126 self.entries.iter()
127 }
128
129 /// Owned, oldest-first clone of the retained entries — the boundary the
130 /// TUI copies across so it never renders off the live global.
131 pub fn snapshot(&self) -> Vec<CommandLogEntry> {
132 self.entries.iter().cloned().collect()
133 }
134
135 /// Drop every entry.
136 pub fn clear(&mut self) {
137 self.entries.clear();
138 }
139}
140
141/// The process-global transcript. Written from the exec chokepoints
142/// (including the off-thread GitHub fetch worker), read by `App` via
143/// [`snapshot`].
144static GLOBAL: LazyLock<Mutex<CommandLog>> = LazyLock::new(|| Mutex::new(CommandLog::new()));
145
146/// Record a finished command on the global log. Lock-poison-safe: a
147/// poisoned mutex drops the entry rather than panicking — logging must
148/// never take down a real operation.
149pub fn record(entry: CommandLogEntry) {
150 if let Ok(mut log) = GLOBAL.lock() {
151 log.push(entry);
152 }
153}
154
155/// Oldest-first snapshot of the global log. Returns empty on a poisoned
156/// lock rather than panicking.
157pub fn snapshot() -> Vec<CommandLogEntry> {
158 GLOBAL.lock().map(|log| log.snapshot()).unwrap_or_default()
159}
160
161/// Clear the global log. Used by the (future) in-TUI "clear logs" action
162/// and by tests that need a clean slate.
163pub fn reset() {
164 if let Ok(mut log) = GLOBAL.lock() {
165 log.clear();
166 }
167}
168
169/// Trim and tail-bound captured output for storage on an entry.
170fn bound_output(stdout: &[u8], stderr: &[u8]) -> String {
171 let stdout = String::from_utf8_lossy(stdout);
172 let stdout = stdout.trim();
173 let stderr = String::from_utf8_lossy(stderr);
174 let stderr = stderr.trim();
175 // Keep BOTH streams: a command that writes progress to stdout and its
176 // diagnostics to stderr before failing must not lose the error text in
177 // the transcript — the modal is for troubleshooting (Codex review #259).
178 // Stdout first, stderr after, joined when both are present.
179 let combined = match (stdout.is_empty(), stderr.is_empty()) {
180 (true, true) => String::new(),
181 (false, true) => stdout.to_string(),
182 (true, false) => stderr.to_string(),
183 (false, false) => format!("{stdout}\n{stderr}"),
184 };
185 if combined.len() <= MAX_OUTPUT_BYTES {
186 return combined;
187 }
188 // Keep the tail (where errors land), snapped to a char boundary.
189 let cut = combined.len() - MAX_OUTPUT_BYTES;
190 let start = (cut..combined.len())
191 .find(|&i| combined.is_char_boundary(i))
192 .unwrap_or(combined.len());
193 combined[start..].to_string()
194}
195
196/// Run `cmd`, recording an entry on the global log, and return the raw
197/// [`Output`] exactly as [`Command::output`] would.
198///
199/// `command` is the resolved, human-readable command line stored on the
200/// entry (the caller builds it so the transcript shows the real argv, not
201/// an opaque `sh -c`). Times the call, captures stdout/stderr + exit, and
202/// records the entry whether the command succeeded, failed, or could not be
203/// spawned. The returned `Result` is the caller's to handle — this wrapper
204/// only observes, it never swallows the error.
205/// [`run_logged`] with a payload written to the child's stdin.
206///
207/// `glab` has no `--body-file`, so the only way to keep a rendered issue
208/// or MR body off the command line — where `ps` exposes it to every
209/// local process — is `glab api --input -` (issue #459). That needs a
210/// real pipe, which [`run_logged`]'s `Command::output()` cannot give:
211/// it closes stdin.
212///
213/// The payload is written in full before the output is read, so a child
214/// that floods stdout before draining stdin would deadlock once both
215/// pipes fill. Issue and MR bodies are a few KB against a 64 KB pipe
216/// buffer, so this stays well inside the margin; a streaming writer
217/// would be the fix if that ever stops being true.
218///
219/// `redact_output` withholds the captured stdout from the transcript.
220/// Keeping a body off the argv is only half the job when the endpoint
221/// echoes it back: the GitLab create responses carry `description`, so
222/// the text the argv no longer leaks would reappear in the modal. Only
223/// the *log* is redacted — the caller still gets the real stdout, which
224/// it needs to read the new `iid` out of. stderr is kept either way,
225/// since that is what makes a failure diagnosable.
226pub fn run_logged_with_stdin(
227 cmd: &mut Command,
228 command: String,
229 stdin: &[u8],
230 redact_output: bool,
231) -> std::io::Result<Output> {
232 run_logged_inner(cmd, command, Some(stdin), redact_output)
233}
234
235/// [`run_logged`] that withholds the response from the transcript. For
236/// reads whose payload is a whole REST object — `glab issue|mr view`
237/// returns `description` — where there is no stdin to key the redaction
238/// off (Codex review #458).
239pub fn run_logged_redacted(cmd: &mut Command, command: String) -> std::io::Result<Output> {
240 run_logged_inner(cmd, command, None, true)
241}
242
243fn run_logged_inner(
244 cmd: &mut Command,
245 command: String,
246 stdin: Option<&[u8]>,
247 redact_output: bool,
248) -> std::io::Result<Output> {
249 use std::io::Write;
250 let start = Instant::now();
251 let result = match stdin {
252 // No payload: `output()` already closes stdin, and piping one we
253 // never write would only add a way to hang.
254 None => cmd.output(),
255 Some(payload) => {
256 cmd
257 .stdin(std::process::Stdio::piped())
258 .stdout(std::process::Stdio::piped())
259 .stderr(std::process::Stdio::piped());
260 (|| {
261 let mut child = cmd.spawn()?;
262 // `take()` then drop at the end of the statement: the child
263 // reads until EOF, so holding the handle open would hang it.
264 child.stdin.take().expect("stdin was piped above").write_all(payload)?;
265 child.wait_with_output()
266 })()
267 }
268 };
269 let duration = start.elapsed();
270 match &result {
271 Ok(out) => record(CommandLogEntry {
272 command,
273 duration,
274 status: CommandStatus::Exited(out.status.code()),
275 output: if redact_output {
276 bound_output(WITHHELD_RESPONSE.as_bytes(), &out.stderr)
277 } else {
278 bound_output(&out.stdout, &out.stderr)
279 },
280 }),
281 Err(_) => record(CommandLogEntry {
282 command,
283 duration,
284 status: CommandStatus::Spawn,
285 output: String::new(),
286 }),
287 }
288 result
289}
290
291/// Stands in for a response the transcript must not keep. Says why, so
292/// the modal does not just look broken.
293const WITHHELD_RESPONSE: &str = "<response withheld: it echoes the submitted body>";
294
295pub fn run_logged(cmd: &mut Command, command: String) -> std::io::Result<Output> {
296 let start = Instant::now();
297 let result = cmd.output();
298 let duration = start.elapsed();
299 match &result {
300 Ok(out) => record(CommandLogEntry {
301 command,
302 duration,
303 status: CommandStatus::Exited(out.status.code()),
304 output: bound_output(&out.stdout, &out.stderr),
305 }),
306 Err(_) => record(CommandLogEntry {
307 command,
308 duration,
309 status: CommandStatus::Spawn,
310 output: String::new(),
311 }),
312 }
313 result
314}