Skip to main content

git_stk/commands/
run.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{Result, anyhow, bail};
5use clap::ArgAction;
6
7use crate::stack;
8use crate::style;
9
10/// Where the scratch worktree lives: under the common git dir, so it is out of
11/// the way of file watchers and ignore rules, invisible to `git status`, and
12/// shared by every worktree of the repo. One fixed path is safe because `run`
13/// holds the git-stk lock for its whole window, so no second run can be using it.
14const SCRATCH_DIR: &str = "git-stk-run-worktree";
15
16/// Run a command on every branch in the stack, bottom-up, with a pass/fail summary.
17///
18/// Answers "does each layer build on its own?" before submitting - each PR is
19/// supposed to be independently green.
20#[derive(Debug, clap::Args)]
21pub struct Run {
22    /// Stop at the first branch whose command fails.
23    #[arg(long, action = ArgAction::SetTrue)]
24    fail_fast: bool,
25    /// Walk your own checkout across the stack instead of using a scratch
26    /// worktree. Needed when the command depends on untracked build output
27    /// (`node_modules`, `target/`) that a fresh worktree will not have; requires
28    /// a clean tree, and moves HEAD while it runs.
29    #[arg(long, action = ArgAction::SetTrue)]
30    no_worktree: bool,
31    /// The command to run on each branch (everything after `--`).
32    #[arg(
33        trailing_var_arg = true,
34        allow_hyphen_values = true,
35        required = true,
36        num_args = 1..,
37        value_name = "CMD"
38    )]
39    command: Vec<String>,
40}
41
42impl crate::commands::Run for Run {
43    fn run(self) -> Result<()> {
44        let original = crate::git::current_branch()?;
45        let branches = stack::current_stack_branches(&original)?;
46
47        if branches.is_empty() {
48            bail!("no stacked branches to run on");
49        }
50
51        let (program, args) = self
52            .command
53            .split_first()
54            .expect("clap requires at least one command word");
55
56        let results = if self.no_worktree {
57            // Walking the user's own checkout: switching with uncommitted
58            // changes would drag them across the stack or fail outright.
59            if !crate::git::worktree_is_clean()? {
60                bail!(
61                    "working tree has uncommitted changes; commit or stash before \
62                     `git stk run --no-worktree`"
63                );
64            }
65            // Always return to where we started, even if a checkout or the
66            // command errors partway through.
67            let result = run_each_in_place(&branches, program, args, self.fail_fast);
68            let _ = crate::git::checkout(&original);
69            result?
70        } else {
71            // A scratch worktree leaves the user's tree - and their HEAD -
72            // completely alone, so uncommitted work is no obstacle.
73            let scratch = ScratchWorktree::create(&branches[0])?;
74            run_each_in(
75                scratch.path(),
76                &cwd_within_repo(),
77                &branches,
78                program,
79                args,
80                self.fail_fast,
81            )?
82        };
83
84        print_summary(&results);
85
86        if results.iter().any(|(_, passed)| !passed) {
87            bail!("`{program}` failed on one or more branches");
88        }
89        Ok(())
90    }
91}
92
93/// A throwaway detached worktree, removed on drop. Detached so it holds no
94/// branch and cannot collide with the user's checkout.
95struct ScratchWorktree {
96    path: PathBuf,
97}
98
99impl ScratchWorktree {
100    fn create(commit: &str) -> Result<Self> {
101        let path = crate::git::git_common_path_absolute(SCRATCH_DIR)?;
102
103        // A leftover from a crashed or killed run. `run` holds the lock for its
104        // whole window, so anything here is stale by definition - reclaim it
105        // rather than failing.
106        if path.exists() {
107            let _ = crate::git::worktree_remove(&path);
108            let _ = std::fs::remove_dir_all(&path);
109        }
110
111        crate::git::worktree_add_detached(&path, commit)?;
112        Ok(Self { path })
113    }
114
115    fn path(&self) -> &Path {
116        &self.path
117    }
118}
119
120impl Drop for ScratchWorktree {
121    fn drop(&mut self) {
122        // Best effort on every exit path. A hard kill skips Drop entirely; the
123        // reclaim in `create` is what covers that.
124        let _ = crate::git::worktree_remove(&self.path);
125    }
126}
127
128/// Run the command against each branch inside `worktree`, moving its detached
129/// HEAD rather than touching any branch.
130fn run_each_in(
131    worktree: &Path,
132    subdirectory: &Path,
133    branches: &[String],
134    program: &str,
135    args: &[String],
136    fail_fast: bool,
137) -> Result<Vec<(String, bool)>> {
138    let mut results = Vec::new();
139    for branch in branches {
140        crate::git::checkout_detached_in(worktree, branch)?;
141        anstream::println!("{}", style::branch(branch));
142        // Re-derived per branch: the subdirectory may not exist on all of them.
143        let dir = mirrored_dir(worktree, subdirectory);
144        let passed = run_once(&dir, program, args)?;
145        results.push((branch.clone(), passed));
146        if !passed && fail_fast {
147            break;
148        }
149    }
150    Ok(results)
151}
152
153/// Check out each branch in the user's own worktree and run the command there.
154fn run_each_in_place(
155    branches: &[String],
156    program: &str,
157    args: &[String],
158    fail_fast: bool,
159) -> Result<Vec<(String, bool)>> {
160    // The user's own directory, not the repo root: this path walks their
161    // checkout, so a command run from a subdirectory must still run there.
162    let here = std::env::current_dir().or_else(|_| crate::git::repo_root())?;
163    let mut results = Vec::new();
164    for branch in branches {
165        crate::git::checkout(branch)?;
166        anstream::println!("{}", style::branch(branch));
167        let passed = run_once(&here, program, args)?;
168        results.push((branch.clone(), passed));
169        if !passed && fail_fast {
170            break;
171        }
172    }
173    Ok(results)
174}
175
176/// Where the user stands, relative to the repo root - so a scratch worktree can
177/// put the command in the same place. Empty at the root, or when the two cannot
178/// be compared.
179fn cwd_within_repo() -> PathBuf {
180    let (Ok(cwd), Ok(root)) = (std::env::current_dir(), crate::git::repo_root()) else {
181        return PathBuf::new();
182    };
183    // Canonicalized before comparing: symlinked or /tmp-style paths would
184    // otherwise fail to match their own repo root.
185    let cwd = cwd.canonicalize().unwrap_or(cwd);
186    let root = root.canonicalize().unwrap_or(root);
187    cwd.strip_prefix(&root)
188        .map(Path::to_path_buf)
189        .unwrap_or_default()
190}
191
192/// `subdirectory` inside `worktree`, when the branch checked out there actually
193/// has it. Falls back to the worktree root rather than failing: a package
194/// directory may simply not exist yet on an earlier branch in the stack.
195fn mirrored_dir(worktree: &Path, subdirectory: &Path) -> PathBuf {
196    let candidate = worktree.join(subdirectory);
197    if candidate.is_dir() {
198        candidate
199    } else {
200        worktree.to_path_buf()
201    }
202}
203
204/// One invocation, with stdio inherited so output streams through live.
205fn run_once(dir: &Path, program: &str, args: &[String]) -> Result<bool> {
206    match Command::new(program).args(args).current_dir(dir).status() {
207        Ok(status) => Ok(status.success()),
208        // The command never launched (e.g. not found). It would fail
209        // identically on every branch, so stop with a clear error rather
210        // than reporting a bogus FAIL down the whole stack - the branches
211        // are fine, the command is what's wrong.
212        Err(error) => Err(spawn_error(program, args, &error)),
213    }
214}
215
216/// A command that could not be spawned, distinguished from one that ran and
217/// exited non-zero. The common cause is passing the whole command as a single
218/// quoted string, so the "program" is really `cmd arg arg` and no such binary
219/// exists; hint at the unquoted form when that shape is detected.
220fn spawn_error(program: &str, args: &[String], error: &std::io::Error) -> anyhow::Error {
221    let mut message = format!("failed to run `{program}`: {error}");
222    if args.is_empty() && program.split_whitespace().count() > 1 {
223        message.push_str(&format!(
224            "\nhint: pass the command unquoted after `--`, e.g. `git stk run -- {program}`"
225        ));
226    }
227    anyhow!(message)
228}
229
230fn print_summary(results: &[(String, bool)]) {
231    let width = results.iter().map(|(b, _)| b.len()).max().unwrap_or(0);
232    anstream::println!();
233    for (branch, passed) in results {
234        let pad = " ".repeat(width - branch.len());
235        let marker = if *passed {
236            style::success("ok")
237        } else {
238            style::paint(style::CLOSED, "FAIL")
239        };
240        anstream::println!("  {}{pad}  {marker}", style::branch(branch));
241    }
242
243    let passed = results.iter().filter(|(_, passed)| *passed).count();
244    let total = results.len();
245    anstream::println!(
246        "{}",
247        style::dim(&format!(
248            "ran on {total} branch{}, {passed} passed",
249            if total == 1 { "" } else { "es" }
250        ))
251    );
252}