1#![forbid(unsafe_code)]
2
3use std::{fmt::Display, panic, sync::mpsc, thread};
4
5use camino::{Utf8Path as Path, Utf8PathBuf as PathBuf};
6use thiserror::Error;
7
8use crate::{
9 config::{Config, ConfigProviderFilter, RemoteProvider, Root},
10 provider::{Filter, ProtocolConfig, Provider as _},
11 tree::Tree,
12};
13
14pub use repo::{BranchName, RemoteName, RemoteUrl, SubmoduleName};
15
16pub mod auth;
17pub mod config;
18pub mod path;
19pub mod provider;
20pub mod repo;
21pub mod table;
22pub mod tree;
23
24#[derive(Debug, Error)]
25pub enum Error {
26 #[error(transparent)]
27 Repo(#[from] repo::Error),
28 #[error(transparent)]
29 Provider(#[from] provider::Error),
30 #[error(transparent)]
31 Tree(#[from] tree::Error),
32 #[error(transparent)]
33 Auth(#[from] auth::Error),
34 #[error("Invalid regex: {message}")]
35 InvalidRegex { message: String },
36 #[error("Cannot detect root directory. Are you working in /?")]
37 CannotDetectRootDirectory,
38 #[error(transparent)]
39 Path(#[from] path::Error),
40}
41
42pub struct Warning(String);
43
44impl Display for Warning {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 write!(f, "{}", self.0)
47 }
48}
49
50struct FindResult {
51 repos: Repos,
52 warnings: Vec<Warning>,
53}
54
55enum Repos {
56 InSearchRoot(repo::Repo),
57 List(Vec<repo::Repo>),
58}
59
60pub fn exec_with_result_channel<'scope, Args, Func, ReportFunc, R, Ret>(
61 f: Func,
62 r: ReportFunc,
63 args: Args,
64) -> Ret
65where
66 Func: for<'a> FnOnce(Args, &'a mpsc::SyncSender<R>) -> Ret + Send + 'scope,
67 ReportFunc: for<'a> FnOnce(&'a mpsc::Receiver<R>) + Send + 'scope,
68 Ret: Send,
69 R: Send,
70 Args: Send,
71{
72 let (tx, rx) = mpsc::sync_channel::<R>(0);
73
74 thread::scope(|s| {
75 let task = s.spawn(move || f(args, &tx));
76
77 let reporter = s.spawn(move || r(&rx));
78
79 if let Err(e) = reporter.join() {
80 panic::resume_unwind(e);
81 }
82
83 match task.join() {
84 Ok(ret) => ret,
85 Err(e) => panic::resume_unwind(e),
86 }
87 })
88}
89
90pub fn send_msg<R>(sender: &mpsc::SyncSender<R>, message: R) {
91 #[expect(
92 clippy::missing_panics_doc,
93 reason = "this is a clear bug, cannot be recovered anyway"
94 )]
95 sender
96 .send(message)
97 .expect("receiving channel must be open until we are done");
98}
99
100fn find_repos(root: &Path, exclusion_pattern: Option<®ex::Regex>) -> Result<FindResult, Error> {
102 let mut repos: Vec<repo::Repo> = Vec::new();
103 let mut repo_in_root = false;
104 let mut warnings = Vec::new();
105
106 for path in tree::find_repo_paths(root)? {
107 if exclusion_pattern
108 .as_ref()
109 .map(|regex| -> Result<bool, Error> { Ok(regex.is_match(path.as_str())) })
110 .transpose()?
111 .unwrap_or(false)
112 {
113 warnings.push(Warning(format!("[skipped] {path}")));
114 continue;
115 }
116
117 let worktree_setup = repo::WorktreeSetup::detect(&path);
118 if path == root {
119 repo_in_root = true;
120 }
121
122 match repo::RepoHandle::open_with_worktree_setup(&path, worktree_setup) {
123 Err(error) => {
124 warnings.push(Warning(format!(
125 "Error opening repo {}{}: {}",
126 path,
127 if worktree_setup.is_worktree() {
128 " as worktree"
129 } else {
130 ""
131 },
132 error
133 )));
134 }
135 Ok(repo) => {
136 let remotes = match repo.remotes() {
137 Ok(remote) => remote,
138 Err(error) => {
139 warnings.push(Warning(format!("{path}: Error getting remotes: {error}")));
140 continue;
141 }
142 };
143
144 let mut results: Vec<repo::Remote> = Vec::new();
145 for remote_name in remotes {
146 match repo.find_remote(&remote_name)? {
147 Some(remote) => {
148 let name = remote.name()?;
149 let url = remote.url()?;
150 let remote_type = match repo::detect_remote_type(&url) {
151 Ok(t) => t,
152 Err(e) => {
153 warnings.push(Warning(format!(
154 "{path}: Could not handle URL {url}. Reason: {e}"
155 )));
156 continue;
157 }
158 };
159
160 results.push(repo::Remote {
161 name,
162 url,
163 remote_type,
164 });
165 }
166 None => {
167 warnings
168 .push(Warning(format!("{path}: Remote {remote_name} not found")));
169 }
170 }
171 }
172 let remotes = results;
173
174 let (namespace, name) = if path == root {
175 (
176 None,
177 if let Some(parent) = root.parent() {
178 path.strip_prefix(parent)
179 .expect("checked for prefix explicitly above")
180 .to_owned()
181 .to_string()
182 } else {
183 warnings.push(Warning(String::from("Getting name of the search root failed. Do you have a git repository in \"/\"?")));
184 continue;
185 },
186 )
187 } else {
188 let name = path
189 .strip_prefix(root)
190 .expect("checked for prefix explicitly above");
191 let namespace = name.parent().expect("path always has a parent");
192 (
193 if namespace != Path::new("") {
194 Some(namespace.to_string())
195 } else {
196 None
197 },
198 name.to_owned().to_string(),
199 )
200 };
201
202 repos.push(repo::Repo {
203 name: repo::RepoName::new(name),
204 namespace: namespace.map(repo::RepoNamespace::new),
205 remotes,
206 worktree_setup,
207 });
208 }
209 }
210 }
211 Ok(FindResult {
212 repos: if repo_in_root {
213 #[expect(clippy::panic, reason = "potential bug")]
214 Repos::InSearchRoot(if repos.len() != 1 {
215 panic!("found multiple repos in root?")
216 } else {
217 repos
218 .pop()
219 .expect("checked len() above and list cannot be empty")
220 })
221 } else {
222 Repos::List(repos)
223 },
224 warnings,
225 })
226}
227
228pub fn find_in_tree(
229 path: &Path,
230 exclusion_pattern: Option<®ex::Regex>,
231) -> Result<(tree::Tree, Vec<Warning>), Error> {
232 let mut warnings = Vec::new();
233
234 let mut result = find_repos(path, exclusion_pattern)?;
235
236 warnings.append(&mut result.warnings);
237
238 let (root, repos) = match result.repos {
239 Repos::InSearchRoot(repo) => (
240 path.parent()
241 .ok_or(Error::CannotDetectRootDirectory)?
242 .to_path_buf(),
243 vec![repo],
244 ),
245 Repos::List(repos) => (path.to_path_buf(), repos),
246 };
247
248 Ok((
249 tree::Tree {
250 root: tree::Root::new(root),
251 repos,
252 },
253 warnings,
254 ))
255}
256
257pub enum SyncTreesMessage {
258 SyncTreeMessage(Result<tree::SyncTreeMessage, (repo::RepoName, Error)>),
259 GetTreeWarning(Warning),
260}
261
262pub fn get_trees(
263 config: Config,
264 result_channel: &mpsc::SyncSender<SyncTreesMessage>,
265) -> Result<Vec<Tree>, Error> {
266 match config {
267 Config::ConfigTrees(config) => Ok(config.trees.into_iter().map(Into::into).collect()),
268 Config::ConfigProvider(config) => {
269 let token = auth::get_token_from_command(&config.token_command)?;
270
271 let filters = config.filters.unwrap_or(ConfigProviderFilter {
272 access: Some(false),
273 owner: Some(false),
274 users: Some(vec![]),
275 groups: Some(vec![]),
276 fork: Some(true),
277 });
278
279 let filter = Filter::new(
280 filters
281 .users
282 .unwrap_or_default()
283 .into_iter()
284 .map(Into::into)
285 .collect(),
286 filters
287 .groups
288 .unwrap_or_default()
289 .into_iter()
290 .map(Into::into)
291 .collect(),
292 filters.owner.unwrap_or(false),
293 filters.access.unwrap_or(false),
294 filters.fork.unwrap_or(true),
295 );
296
297 if filter.empty() {
298 send_msg(
299 result_channel,
300 SyncTreesMessage::GetTreeWarning(Warning(
301 "The configuration does not contain any filters, so no repos will match"
302 .to_owned(),
303 )),
304 );
305 }
306
307 let repos = match config.provider {
308 RemoteProvider::Github => {
309 provider::Github::new(filter, token, config.api_url.map(provider::Url::new))?
310 .get_repos(
311 config.worktree.unwrap_or(false).into(),
312 if config.force_ssh.unwrap_or(false) {
313 ProtocolConfig::ForceSsh
314 } else {
315 ProtocolConfig::Default
316 },
317 config.remote_name.map(RemoteName::new),
318 )?
319 }
320 RemoteProvider::Gitlab => {
321 provider::Gitlab::new(filter, token, config.api_url.map(provider::Url::new))?
322 .get_repos(
323 config.worktree.unwrap_or(false).into(),
324 if config.force_ssh.unwrap_or(false) {
325 ProtocolConfig::ForceSsh
326 } else {
327 ProtocolConfig::Default
328 },
329 config.remote_name.map(RemoteName::new),
330 )?
331 }
332 };
333
334 let mut trees = vec![];
335
336 #[expect(clippy::iter_over_hash_type, reason = "fine in this case")]
337 for (namespace, repos) in repos {
338 let tree = Tree {
339 root: Root::from_path_buf(if let Some(namespace) = namespace {
340 PathBuf::from(&config.root).join(namespace.as_str())
341 } else {
342 PathBuf::from(&config.root)
343 })
344 .into(),
345 repos,
346 };
347 trees.push(tree);
348 }
349 Ok(trees)
350 }
351 }
352}