Skip to main content

loom_core/workspace/
exec.rs

1use std::process::Command;
2
3use anyhow::Result;
4
5use crate::manifest::WorkspaceManifest;
6
7/// Result of running a command across repos.
8#[derive(Debug)]
9pub struct ExecResult {
10    pub results: Vec<RepoExecResult>,
11}
12
13/// Result of running a command in a single repo.
14#[derive(Debug)]
15pub struct RepoExecResult {
16    pub repo_name: String,
17    pub exit_code: i32,
18    pub success: bool,
19}
20
21impl ExecResult {
22    /// Whether all repos succeeded.
23    pub fn all_success(&self) -> bool {
24        self.results.iter().all(|r| r.success)
25    }
26}
27
28/// Run a command in each repo's worktree sequentially.
29///
30/// Stdout/stderr are inherited (streamed to the terminal).
31pub fn exec_in_workspace(manifest: &WorkspaceManifest, cmd: &[String]) -> Result<ExecResult> {
32    if cmd.is_empty() {
33        anyhow::bail!("No command provided.");
34    }
35
36    let mut results = Vec::new();
37
38    for repo in &manifest.repos {
39        if !repo.worktree_path.exists() {
40            eprintln!("=== {} === (missing, skipped)", repo.name);
41            results.push(RepoExecResult {
42                repo_name: repo.name.clone(),
43                exit_code: -1,
44                success: false,
45            });
46            continue;
47        }
48
49        eprintln!("=== {} ===", repo.name);
50        tracing::debug!(
51            repo = %repo.name,
52            command = %cmd.join(" "),
53            dir = %repo.worktree_path.display(),
54            "executing command"
55        );
56
57        let status = Command::new(&cmd[0])
58            .args(&cmd[1..])
59            .current_dir(&repo.worktree_path)
60            .env("LC_ALL", "C")
61            .status();
62
63        match status {
64            Ok(s) => {
65                let code = s.code().unwrap_or(-1);
66                results.push(RepoExecResult {
67                    repo_name: repo.name.clone(),
68                    exit_code: code,
69                    success: s.success(),
70                });
71            }
72            Err(e) => {
73                eprintln!(
74                    "  Failed to run '{}' in {}: {}",
75                    cmd[0],
76                    repo.worktree_path.display(),
77                    e
78                );
79                if e.kind() == std::io::ErrorKind::NotFound {
80                    eprintln!(
81                        "  Hint: '{}' was not found in PATH. Check your shell environment.",
82                        cmd[0]
83                    );
84                }
85                results.push(RepoExecResult {
86                    repo_name: repo.name.clone(),
87                    exit_code: -1,
88                    success: false,
89                });
90            }
91        }
92    }
93
94    Ok(ExecResult { results })
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::manifest::RepoManifestEntry;
101
102    #[test]
103    fn test_exec_empty_command() {
104        let manifest = WorkspaceManifest {
105            name: "test-ws".to_string(),
106            branch: None,
107            created: chrono::Utc::now(),
108            base_branch: None,
109            preset: None,
110            repos: vec![],
111        };
112
113        let result = exec_in_workspace(&manifest, &[]);
114        assert!(result.is_err());
115    }
116
117    #[test]
118    fn test_exec_in_real_repo() {
119        let dir = tempfile::tempdir().unwrap();
120        let repo_path = dir.path().join("my-repo");
121        std::fs::create_dir_all(&repo_path).unwrap();
122        std::process::Command::new("git")
123            .args(["init", "-b", "main", &repo_path.to_string_lossy()])
124            .env("LC_ALL", "C")
125            .output()
126            .unwrap();
127
128        let manifest = WorkspaceManifest {
129            name: "test-ws".to_string(),
130            branch: None,
131            created: chrono::Utc::now(),
132            base_branch: None,
133            preset: None,
134            repos: vec![RepoManifestEntry {
135                name: "my-repo".to_string(),
136                original_path: repo_path.clone(),
137                worktree_path: repo_path,
138                branch: "main".to_string(),
139                remote_url: String::new(),
140            }],
141        };
142
143        let result =
144            exec_in_workspace(&manifest, &["echo".to_string(), "hello".to_string()]).unwrap();
145        assert!(result.all_success());
146        assert_eq!(result.results.len(), 1);
147        assert_eq!(result.results[0].exit_code, 0);
148    }
149
150    #[test]
151    fn test_exec_missing_repo() {
152        let manifest = WorkspaceManifest {
153            name: "test-ws".to_string(),
154            branch: None,
155            created: chrono::Utc::now(),
156            base_branch: None,
157            preset: None,
158            repos: vec![RepoManifestEntry {
159                name: "missing-repo".to_string(),
160                original_path: std::path::PathBuf::from("/nonexistent"),
161                worktree_path: std::path::PathBuf::from("/nonexistent"),
162                branch: "main".to_string(),
163                remote_url: String::new(),
164            }],
165        };
166
167        let result =
168            exec_in_workspace(&manifest, &["echo".to_string(), "hello".to_string()]).unwrap();
169        assert!(!result.all_success());
170        assert_eq!(result.results[0].exit_code, -1);
171    }
172
173    #[test]
174    fn test_exec_command_not_found() {
175        let dir = tempfile::tempdir().unwrap();
176        let repo_path = dir.path().join("my-repo");
177        std::fs::create_dir_all(&repo_path).unwrap();
178        std::process::Command::new("git")
179            .args(["init", "-b", "main", &repo_path.to_string_lossy()])
180            .env("LC_ALL", "C")
181            .output()
182            .unwrap();
183
184        let manifest = WorkspaceManifest {
185            name: "test-ws".to_string(),
186            branch: None,
187            created: chrono::Utc::now(),
188            base_branch: None,
189            preset: None,
190            repos: vec![RepoManifestEntry {
191                name: "my-repo".to_string(),
192                original_path: repo_path.clone(),
193                worktree_path: repo_path,
194                branch: "main".to_string(),
195                remote_url: String::new(),
196            }],
197        };
198
199        let result =
200            exec_in_workspace(&manifest, &["nonexistent-binary-xyz-12345".to_string()]).unwrap();
201        assert!(!result.all_success());
202        assert_eq!(result.results[0].exit_code, -1);
203        assert!(!result.results[0].success);
204    }
205}