Skip to main content

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}