Skip to main content

git_stk/commands/
run.rs

1use std::process::Command;
2
3use anyhow::{Result, anyhow, bail};
4use clap::ArgAction;
5
6use crate::stack;
7use crate::style;
8
9/// Run a command on every branch in the stack, bottom-up, with a pass/fail summary.
10///
11/// Answers "does each layer build on its own?" before submitting - each PR is
12/// supposed to be independently green.
13#[derive(Debug, clap::Args)]
14pub struct Run {
15    /// Stop at the first branch whose command fails.
16    #[arg(long, action = ArgAction::SetTrue)]
17    fail_fast: bool,
18    /// The command to run on each branch (everything after `--`).
19    #[arg(
20        trailing_var_arg = true,
21        allow_hyphen_values = true,
22        required = true,
23        num_args = 1..,
24        value_name = "CMD"
25    )]
26    command: Vec<String>,
27}
28
29impl crate::commands::Run for Run {
30    fn run(self) -> Result<()> {
31        // Switching branches with uncommitted changes would drag them across
32        // the stack or fail outright; require a clean tree.
33        if !crate::git::worktree_is_clean()? {
34            bail!("working tree has uncommitted changes; commit or stash before `git stk run`");
35        }
36
37        let original = crate::git::current_branch()?;
38        let branches = stack::current_stack_branches(&original)?;
39
40        if branches.is_empty() {
41            bail!("no stacked branches to run on");
42        }
43
44        let (program, args) = self
45            .command
46            .split_first()
47            .expect("clap requires at least one command word");
48
49        // Always return to where we started, even if a checkout or the
50        // command errors partway through.
51        let result = run_each(&branches, program, args, self.fail_fast);
52        let _ = crate::git::checkout(&original);
53        let results = result?;
54
55        print_summary(&results);
56
57        if results.iter().any(|(_, passed)| !passed) {
58            bail!("`{program}` failed on one or more branches");
59        }
60        Ok(())
61    }
62}
63
64/// Check out each branch in turn and run the command, collecting pass/fail.
65fn run_each(
66    branches: &[String],
67    program: &str,
68    args: &[String],
69    fail_fast: bool,
70) -> Result<Vec<(String, bool)>> {
71    let mut results = Vec::new();
72    for branch in branches {
73        crate::git::checkout(branch)?;
74        anstream::println!("{}", style::branch(branch));
75        // Inherit stdio so the command's output streams through live.
76        let passed = match Command::new(program).args(args).status() {
77            Ok(status) => status.success(),
78            // The command never launched (e.g. not found). It would fail
79            // identically on every branch, so stop with a clear error rather
80            // than reporting a bogus FAIL down the whole stack - the branches
81            // are fine, the command is what's wrong.
82            Err(error) => return Err(spawn_error(program, args, &error)),
83        };
84        results.push((branch.clone(), passed));
85        if !passed && fail_fast {
86            break;
87        }
88    }
89    Ok(results)
90}
91
92/// A command that could not be spawned, distinguished from one that ran and
93/// exited non-zero. The common cause is passing the whole command as a single
94/// quoted string, so the "program" is really `cmd arg arg` and no such binary
95/// exists; hint at the unquoted form when that shape is detected.
96fn spawn_error(program: &str, args: &[String], error: &std::io::Error) -> anyhow::Error {
97    let mut message = format!("failed to run `{program}`: {error}");
98    if args.is_empty() && program.split_whitespace().count() > 1 {
99        message.push_str(&format!(
100            "\nhint: pass the command unquoted after `--`, e.g. `git stk run -- {program}`"
101        ));
102    }
103    anyhow!(message)
104}
105
106fn print_summary(results: &[(String, bool)]) {
107    let width = results.iter().map(|(b, _)| b.len()).max().unwrap_or(0);
108    anstream::println!();
109    for (branch, passed) in results {
110        let pad = " ".repeat(width - branch.len());
111        let marker = if *passed {
112            style::success("ok")
113        } else {
114            style::paint(style::CLOSED, "FAIL")
115        };
116        anstream::println!("  {}{pad}  {marker}", style::branch(branch));
117    }
118
119    let passed = results.iter().filter(|(_, passed)| *passed).count();
120    let total = results.len();
121    anstream::println!(
122        "{}",
123        style::dim(&format!(
124            "ran on {total} branch{}, {passed} passed",
125            if total == 1 { "" } else { "es" }
126        ))
127    );
128}