Skip to main content

grm/
lib.rs

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
100/// Find all git repositories under root, recursively
101fn find_repos(root: &Path, exclusion_pattern: Option<&regex::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<&regex::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}