loom_core/workspace/
exec.rs1use std::process::Command;
2
3use anyhow::Result;
4
5use crate::manifest::WorkspaceManifest;
6
7#[derive(Debug)]
9pub struct ExecResult {
10 pub results: Vec<RepoExecResult>,
11}
12
13#[derive(Debug)]
15pub struct RepoExecResult {
16 pub repo_name: String,
17 pub exit_code: i32,
18 pub success: bool,
19}
20
21impl ExecResult {
22 pub fn all_success(&self) -> bool {
24 self.results.iter().all(|r| r.success)
25 }
26}
27
28pub 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}