Skip to main content

git_worktree_manager/operations/
run.rs

1//! `gw run <cmd>` — fan a command out across all worktrees in scope.
2
3use std::io::Write;
4use std::path::Path;
5use std::process::Command;
6
7use crate::error::Result;
8use crate::scope;
9
10pub fn run_in_scope(
11    cwd: &Path,
12    cmd: &[String],
13    only: Option<&str>,
14    no_main: bool,
15    jobs: usize,
16    continue_on_error: bool,
17) -> Result<i32> {
18    let mut stdout = std::io::stdout().lock();
19    run_in_scope_to_writer(
20        cwd,
21        cmd,
22        only,
23        no_main,
24        jobs,
25        continue_on_error,
26        &mut stdout,
27    )
28}
29
30/// Returns the last non-zero exit code observed (or 0 if all succeeded).
31/// In parallel mode (`jobs > 1`), `continue_on_error = false` aborts at batch
32/// boundaries — siblings already started in the same batch run to completion.
33pub fn run_in_scope_to_writer<W: Write>(
34    cwd: &Path,
35    cmd: &[String],
36    only: Option<&str>,
37    no_main: bool,
38    jobs: usize,
39    continue_on_error: bool,
40    out: &mut W,
41) -> Result<i32> {
42    let scope = scope::discover_scope(cwd)?;
43    let mut targets: Vec<&scope::ScopedWorktree> = scope
44        .worktrees()
45        .iter()
46        .filter(|w| !no_main || !w.is_main)
47        .filter(|w| match only {
48            // Match against either the branch name (what users type) or the
49            // worktree directory basename (path layout). Branch is checked
50            // first because it's the natural identifier — `gw ls`, target
51            // resolution, and the Quick Start examples all use it.
52            Some(g) => {
53                w.branch
54                    .as_deref()
55                    .map(|b| glob_match(g, b))
56                    .unwrap_or(false)
57                    || glob_match(g, &w.name)
58            }
59            None => true,
60        })
61        .collect();
62    targets.sort_by_key(|w| (!w.is_main, w.name.clone())); // main first
63
64    let jobs = jobs.max(1);
65    let mut last_failure: i32 = 0;
66    let mut i = 0;
67    while i < targets.len() {
68        let batch_end = (i + jobs).min(targets.len());
69
70        // Spawn each child in this batch on its own thread, capturing
71        // (target_index, captured_output, exit_code).
72        let handles: Vec<std::thread::JoinHandle<Result<(usize, String, i32)>>> = (i..batch_end)
73            .map(|k| {
74                let w = targets[k];
75                let name = w.name.clone();
76                let path = w.path.clone();
77                let cmd_v: Vec<String> = cmd.to_vec();
78                std::thread::spawn(move || {
79                    let (s, code) = run_capture_one(&name, &path, &cmd_v)?;
80                    Ok((k, s, code))
81                })
82            })
83            .collect();
84
85        // Collect, then sort by target index so output is emitted in target order
86        // (NOT completion order). This preserves the "main first, then alphabetical"
87        // ordering established by the earlier sort_by_key.
88        let mut results: Vec<(usize, String, i32)> = handles
89            .into_iter()
90            .map(|h| h.join().expect("worker panicked"))
91            .collect::<Result<Vec<_>>>()?;
92        results.sort_by_key(|(k, _, _)| *k);
93
94        for (_, s, code) in results {
95            let _ = out.write_all(s.as_bytes());
96            if code != 0 {
97                last_failure = code;
98                if !continue_on_error {
99                    return Ok(last_failure);
100                }
101            }
102        }
103        i = batch_end;
104    }
105    Ok(last_failure)
106}
107
108pub(crate) fn run_capture_one(name: &str, path: &Path, cmd: &[String]) -> Result<(String, i32)> {
109    if cmd.is_empty() {
110        return Err(crate::error::CwError::Other(
111            "run_capture_one: empty command".into(),
112        ));
113    }
114    let prefix = format!("[{}] ", name);
115    let mut child = Command::new(&cmd[0])
116        .args(&cmd[1..])
117        .current_dir(path)
118        .stdout(std::process::Stdio::piped())
119        .stderr(std::process::Stdio::piped())
120        .spawn()?;
121    let stdout = child.stdout.take().expect("piped stdout");
122    let stderr = child.stderr.take().expect("piped stderr");
123
124    let prefix_a = prefix.clone();
125    let h1 = std::thread::spawn(move || pipe_with_prefix(stdout, &prefix_a));
126    let prefix_b = prefix.clone();
127    let h2 = std::thread::spawn(move || pipe_with_prefix(stderr, &prefix_b));
128
129    let so = h1.join().unwrap_or_default();
130    let se = h2.join().unwrap_or_default();
131    let mut combined = so;
132    combined.push_str(&se);
133
134    let status = child.wait()?;
135    Ok((combined, status.code().unwrap_or(1)))
136}
137
138fn pipe_with_prefix<R: std::io::Read>(reader: R, prefix: &str) -> String {
139    use std::io::BufRead;
140    let buf = std::io::BufReader::new(reader);
141    let mut out = String::new();
142    for line in buf.lines().map_while(|l| l.ok()) {
143        out.push_str(prefix);
144        out.push_str(&line);
145        out.push('\n');
146    }
147    out
148}
149
150fn glob_match(pattern: &str, name: &str) -> bool {
151    // Empty pattern matches only empty name.
152    if pattern.is_empty() {
153        return name.is_empty();
154    }
155    // Minimal glob: supports '*' anywhere. No '?', no character classes.
156    let parts: Vec<&str> = pattern.split('*').collect();
157    let mut idx = 0usize;
158    let last = parts.len() - 1;
159    if !pattern.starts_with('*') && !name.starts_with(parts[0]) {
160        return false;
161    }
162    for (i, part) in parts.iter().enumerate() {
163        if part.is_empty() {
164            continue;
165        }
166        // For the last segment when the pattern is not '*'-terminated, use
167        // rfind so that the trailing literal is anchored to end-of-string
168        // rather than matching the first occurrence in the remaining slice.
169        let pos = if i == last && !pattern.ends_with('*') {
170            name[idx..].rfind(part)
171        } else {
172            name[idx..].find(part)
173        };
174        match pos {
175            Some(p) => idx += p + part.len(),
176            None => return false,
177        }
178        if i == last && !pattern.ends_with('*') && idx != name.len() {
179            return false;
180        }
181    }
182    true
183}
184
185#[cfg(test)]
186mod glob_tests {
187    use super::glob_match;
188
189    #[test]
190    fn empty_pattern_matches_only_empty() {
191        assert!(glob_match("", ""));
192        assert!(!glob_match("", "anything"));
193    }
194
195    #[test]
196    fn star_matches_anything() {
197        assert!(glob_match("*", ""));
198        assert!(glob_match("*", "anything"));
199    }
200
201    #[test]
202    fn literal_matches_exactly() {
203        assert!(glob_match("foo", "foo"));
204        assert!(!glob_match("foo", "foobar"));
205        assert!(!glob_match("foo", "barfoo"));
206        assert!(!glob_match("foo", "fo"));
207    }
208
209    #[test]
210    fn leading_star() {
211        assert!(glob_match("*foo", "foo"));
212        assert!(glob_match("*foo", "barfoo"));
213        assert!(!glob_match("*foo", "foobar"));
214    }
215
216    #[test]
217    fn trailing_star() {
218        assert!(glob_match("foo*", "foo"));
219        assert!(glob_match("foo*", "foobar"));
220        assert!(!glob_match("foo*", "barfoo"));
221    }
222
223    #[test]
224    fn double_star_collapses() {
225        // Adjacent '*' is harmless — empty parts skip in the loop.
226        assert!(glob_match("**foo", "foo"));
227        assert!(glob_match("foo**bar", "foobar"));
228    }
229
230    #[test]
231    fn middle_star() {
232        assert!(glob_match("a*b", "ab"));
233        assert!(glob_match("a*b", "axb"));
234        assert!(!glob_match("a*b", "axc"));
235    }
236
237    #[test]
238    fn trailing_literal_anchored_to_end_with_repeated_substring() {
239        // The bug-fix case: trailing literal must rfind, not find.
240        assert!(glob_match("*a*a", "aaaa"));
241        assert!(glob_match("*a", "aaaa"));
242        assert!(!glob_match("*a", "aaab"));
243    }
244
245    #[test]
246    fn realistic_feat_glob() {
247        assert!(glob_match("feat-*", "feat-login"));
248        assert!(glob_match("feat-*", "feat-"));
249        assert!(!glob_match("feat-*", "bug-feat-x"));
250    }
251}