gwm/launcher.rs
1//! Configurable command launchers for the TUI `l` (git_tui) and `R`
2//! (review) keybindings — issue #75.
3//!
4//! Both keybindings share the same mini-API: take a `command` template
5//! string from `.gwm.toml`, substitute placeholders, split with
6//! `shell-words`, and exec'd with `cwd = worktree.path`. The only
7//! per-keybinding difference is the placeholder set: `[git_tui]` only
8//! cares about `{path}`, while `[review]` also exposes `{base}`,
9//! `{head}`, and `{diff}` (a lazily-materialised tempfile carrying
10//! `git diff {base}..{head}`).
11//!
12//! This module owns the shared machinery (placeholder expansion,
13//! base resolution, missing-binary probe) so the TUI event loop and
14//! `gwm doctor` can each consume it through the same surface.
15
16use crate::config::ResolvedLauncher;
17use crate::error::{GwmError, Result};
18use git2::Repository;
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22/// Placeholder substitution context. `base` and `head` are only required
23/// by the review launcher; the git_tui launcher passes `None` for both.
24/// `worktree_path` is mandatory because both launchers `cd` into it
25/// before exec'ing the command.
26#[derive(Debug, Clone)]
27pub struct LauncherContext<'a> {
28 pub worktree_path: &'a Path,
29 pub base: Option<&'a str>,
30 pub head: Option<&'a str>,
31 /// Repo handle used to materialise the `{diff}` tempfile on demand.
32 /// `None` disables the `{diff}` placeholder (status-bar error if the
33 /// template references it).
34 pub repo_workdir: Option<&'a Path>,
35}
36
37/// Output of expanding a launcher template. `argv[0]` is the binary,
38/// `argv[1..]` are the arguments. `diff_file` is `Some` only when the
39/// template referenced `{diff}` — its `Drop` impl cleans the tempfile
40/// up once the spawned process has consumed it.
41#[derive(Debug)]
42pub struct ExpandedCommand {
43 pub argv: Vec<String>,
44 /// Kept alive for the duration of the spawned process so the tempfile
45 /// the `{diff}` placeholder points at is not unlinked before the
46 /// reviewer reads it. `None` when the template didn't use `{diff}`.
47 pub diff_file: Option<tempfile::NamedTempFile>,
48}
49
50impl ExpandedCommand {
51 /// Binary name (`argv[0]`), or `None` for an empty argv (parser
52 /// returned `[]`, which means the user typed e.g. `command = ""`).
53 pub fn binary(&self) -> Option<&str> {
54 self.argv.first().map(|s| s.as_str())
55 }
56}
57
58/// Substitute `{base}`, `{head}`, `{path}`, `{diff}` in `template` and
59/// split the result with `shell-words`. Materialises a tempfile holding
60/// `git diff {base}..{head}` iff the template references `{diff}` —
61/// this is the lazy contract from the issue body: a template that
62/// doesn't use `{diff}` must not spawn `git diff`.
63///
64/// Errors:
65/// - `Config` — `{base}` / `{head}` / `{diff}` referenced without the
66/// matching context field set.
67/// - `Other` — `shell-words` parse failure (unbalanced quotes etc.).
68pub fn expand_command(template: &str, ctx: &LauncherContext<'_>) -> Result<ExpandedCommand> {
69 let uses_base = template.contains("{base}");
70 let uses_head = template.contains("{head}");
71 let uses_diff = template.contains("{diff}");
72
73 if uses_base && ctx.base.is_none() {
74 return Err(GwmError::Config(
75 "template uses {base} but no base ref was resolved".into(),
76 ));
77 }
78 if uses_head && ctx.head.is_none() {
79 return Err(GwmError::Config(
80 "template uses {head} but no head ref was resolved".into(),
81 ));
82 }
83
84 let worktree_path = ctx.worktree_path.to_string_lossy();
85 let path_str = shell_words::quote(&worktree_path);
86 let mut expanded = template.replace("{path}", &path_str);
87 if let Some(b) = ctx.base {
88 expanded = expanded.replace("{base}", b);
89 }
90 if let Some(h) = ctx.head {
91 expanded = expanded.replace("{head}", h);
92 }
93
94 let diff_file = if uses_diff {
95 let (b, h) = match (ctx.base, ctx.head) {
96 (Some(b), Some(h)) => (b, h),
97 _ => {
98 return Err(GwmError::Config(
99 "template uses {diff} but {base}/{head} could not be resolved".into(),
100 ))
101 }
102 };
103 let workdir = ctx.repo_workdir.ok_or_else(|| {
104 GwmError::Config("template uses {diff} but no repo workdir was provided to the launcher".into())
105 })?;
106 let tmp = materialise_diff(workdir, b, h)?;
107 let diff_path = shell_words::quote(&tmp.path().to_string_lossy()).into_owned();
108 expanded = expanded.replace("{diff}", &diff_path);
109 Some(tmp)
110 } else {
111 None
112 };
113
114 let argv =
115 shell_words::split(&expanded).map_err(|e| GwmError::Other(format!("invalid shell line '{}': {}", expanded, e)))?;
116 Ok(ExpandedCommand { argv, diff_file })
117}
118
119/// Build the argv tail (after `-C <workdir>`) for `git diff
120/// <base>..<head>` with the `--end-of-options` guard interposed
121/// (issue #100). Both `base` and `head` flow from user-controlled
122/// surfaces (`branch.<n>.merge`, `branch.<n>.gwm-base`,
123/// `[review].default_base`, branch names), so a value like
124/// `--upload-pack=/tmp/x` would otherwise be parsed as a git option
125/// (the CVE-2017-1000117 shape) on susceptible git versions. The
126/// `--end-of-options` separator forces every token after it to be
127/// treated as a positional ref — modern git mandates this defensive
128/// pattern even when its own argument parser would not be tricked
129/// today.
130///
131/// Public for unit-testing the contract; the wrappers
132/// `materialise_diff` and `count_commits_ahead` are the actual
133/// shell-out sites.
134pub fn git_diff_argv(base: &str, head: &str) -> Vec<String> {
135 vec!["diff".into(), "--end-of-options".into(), format!("{}..{}", base, head)]
136}
137
138/// Build the argv tail (after `-C <workdir>`) for `git rev-list
139/// --count <base>..<head>` with the same `--end-of-options` guard as
140/// [`git_diff_argv`] (issue #100).
141pub fn git_rev_list_count_argv(base: &str, head: &str) -> Vec<String> {
142 vec![
143 "rev-list".into(),
144 "--count".into(),
145 "--end-of-options".into(),
146 format!("{}..{}", base, head),
147 ]
148}
149
150/// Shell out to `git diff <base>..<head>` from `workdir`, write the
151/// output into a tempfile, and return the handle. The caller keeps
152/// the handle alive (the tempfile is unlinked on drop) so the consumer
153/// process can read it via the `{diff}` substitution.
154///
155/// Failures of `git diff` are surfaced as `CommandFailed` rather than
156/// silently producing an empty file — the user pressed `R` expecting a
157/// diff, so an empty buffer would mask a real configuration problem.
158fn materialise_diff(workdir: &Path, base: &str, head: &str) -> Result<tempfile::NamedTempFile> {
159 let output = Command::new("git")
160 .arg("-C")
161 .arg(workdir)
162 .args(git_diff_argv(base, head))
163 .output()
164 .map_err(|e| GwmError::CommandFailed(format!("git diff failed to spawn: {}", e)))?;
165 if !output.status.success() {
166 return Err(GwmError::CommandFailed(format!(
167 "git diff {}..{} exited with status {:?}: {}",
168 base,
169 head,
170 output.status.code(),
171 String::from_utf8_lossy(&output.stderr).trim()
172 )));
173 }
174 let mut tmp = tempfile::Builder::new()
175 .prefix("gwm-review-")
176 .suffix(".diff")
177 .tempfile()
178 .map_err(GwmError::Io)?;
179 use std::io::Write as _;
180 tmp.write_all(&output.stdout).map_err(GwmError::Io)?;
181 tmp.flush().map_err(GwmError::Io)?;
182 Ok(tmp)
183}
184
185/// Resolve the review base for `branch` following the chain documented
186/// in issue #75:
187///
188/// 1. `branch.<name>.merge` (the upstream tracking ref) — except when it
189/// points at the branch itself (#117). After the canonical `git push
190/// -u origin <branch>` flow, git records `merge = refs/heads/<same-
191/// branch>`, which would make `git diff <branch>..<branch>` empty and
192/// silently swallow the `R: review` keystroke. Treat that case as
193/// "no usable upstream" and fall through.
194/// 2. `branch.<name>.gwm-base` — set by `gwm create` on the new branch
195/// so the original parent is recoverable even without an upstream.
196/// 3. `[review].default_base` from `.gwm.toml`.
197/// 4. `"dev"` (gwm's project convention).
198/// 5. `"main"` (universal git default).
199///
200/// Returns the first non-empty hit; never errors (the final `"main"`
201/// is a guaranteed sentinel). The string is the user-facing ref name
202/// — the launcher passes it to `git diff` / `git rev-list` directly,
203/// so it must be a name git understands.
204pub fn resolve_review_base(repo: &Repository, branch: &str, default_base: Option<&str>) -> String {
205 if let Some(upstream) = read_branch_merge(repo, branch) {
206 if upstream != branch {
207 return upstream;
208 }
209 }
210 if let Some(gwm_base) = read_branch_config(repo, branch, "gwm-base") {
211 return gwm_base;
212 }
213 if let Some(d) = default_base.map(str::trim).filter(|s| !s.is_empty()) {
214 return d.to_string();
215 }
216 // Final fallback: prefer `dev` when it exists locally (gwm's project
217 // convention), otherwise `main` (universal git default). Returning
218 // `dev` blindly when the repo only has `main` would make the launcher's
219 // subsequent `git rev-list` / `git diff` calls fail loudly — caught by
220 // Copilot's review on PR #76.
221 if branch_exists(repo, "dev") {
222 "dev".to_string()
223 } else {
224 "main".to_string()
225 }
226}
227
228fn branch_exists(repo: &Repository, name: &str) -> bool {
229 repo.find_branch(name, git2::BranchType::Local).is_ok()
230}
231
232/// Persist `branch.<name>.gwm-base = <base>` so the review base chain
233/// can recover the parent ref even if the upstream is dropped. Called
234/// by `gwm create` after a successful `git worktree add`.
235pub fn write_gwm_base(repo: &Repository, branch: &str, base: &str) -> Result<()> {
236 let mut cfg = repo.config()?;
237 cfg.set_str(&format!("branch.{}.gwm-base", branch), base)?;
238 Ok(())
239}
240
241fn read_branch_merge(repo: &Repository, branch: &str) -> Option<String> {
242 let cfg = repo.config().ok()?;
243 let raw = cfg.get_string(&format!("branch.{}.merge", branch)).ok()?;
244 let trimmed = raw.trim();
245 if trimmed.is_empty() {
246 return None;
247 }
248 // `branch.<name>.merge` is stored as a refspec like `refs/heads/dev`;
249 // surface the short name so the value is fit for `git diff` / `git
250 // rev-list` without further massaging.
251 Some(trimmed.strip_prefix("refs/heads/").unwrap_or(trimmed).to_string())
252}
253
254fn read_branch_config(repo: &Repository, branch: &str, leaf: &str) -> Option<String> {
255 let cfg = repo.config().ok()?;
256 let raw = cfg.get_string(&format!("branch.{}.{}", branch, leaf)).ok()?;
257 let trimmed = raw.trim();
258 if trimmed.is_empty() {
259 None
260 } else {
261 Some(trimmed.to_string())
262 }
263}
264
265/// Count commits in `head` not in `base` (`git rev-list --count
266/// {base}..{head}` shelled out from `workdir`). Returns `0` when the
267/// shell-out fails — the `R` keybinding defers to the caller's
268/// `skip_when_no_changes` knob to decide what to do with the count.
269pub fn count_commits_ahead(workdir: &Path, base: &str, head: &str) -> u32 {
270 let output = Command::new("git")
271 .arg("-C")
272 .arg(workdir)
273 .args(git_rev_list_count_argv(base, head))
274 .output();
275 let Ok(out) = output else { return 0 };
276 if !out.status.success() {
277 return 0;
278 }
279 String::from_utf8_lossy(&out.stdout).trim().parse::<u32>().unwrap_or(0)
280}
281
282/// Probe `$PATH` for the binary in an [`ExpandedCommand`]. Returns the
283/// absolute path on hit, `None` otherwise — the status bar can then
284/// surface a precise error without trying to spawn a missing file.
285pub fn locate_binary(expanded: &ExpandedCommand) -> Option<PathBuf> {
286 let bin = expanded.binary()?;
287 which::which(bin).ok()
288}
289
290/// Probe whether the resolved launcher's binary exists on $PATH. Used
291/// by [`gwm doctor`](crate::doctor) to emit a warning (exit code 1)
292/// when the configured review/git_tui binary is missing — the launcher
293/// itself is opt-in, so this is advisory, not a hard failure.
294///
295/// Returns the binary name on miss so the doctor check can include it
296/// in the message verbatim ("not on PATH: lumen"). Returns `None` if
297/// the binary resolves or if the launcher has no parseable argv.
298pub fn missing_binary_for(launcher: &ResolvedLauncher) -> Option<String> {
299 // Strip the placeholders before tokenising — `shell_words` would
300 // happily eat the `{path}` literal, but we only care about argv[0]
301 // here so a quick replace keeps things simple. The launcher itself
302 // does proper expansion at exec time.
303 let cleaned = launcher
304 .command
305 .replace("{base}", "BASE")
306 .replace("{head}", "HEAD")
307 .replace("{path}", "PATH")
308 .replace("{diff}", "/tmp/diff");
309 let tokens = shell_words::split(&cleaned).ok()?;
310 let bin = tokens.into_iter().find(|t| !t.contains('='))?;
311 if which::which(&bin).is_ok() {
312 None
313 } else {
314 Some(bin)
315 }
316}