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.
205pub fn run_logged(cmd: &mut Command, command: String) -> std::io::Result<Output> {
206 let start = Instant::now();
207 let result = cmd.output();
208 let duration = start.elapsed();
209 match &result {
210 Ok(out) => record(CommandLogEntry {
211 command,
212 duration,
213 status: CommandStatus::Exited(out.status.code()),
214 output: bound_output(&out.stdout, &out.stderr),
215 }),
216 Err(_) => record(CommandLogEntry {
217 command,
218 duration,
219 status: CommandStatus::Spawn,
220 output: String::new(),
221 }),
222 }
223 result
224}