grm/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::{
4    borrow::Cow,
5    fmt::{self, Display},
6    path::Path,
7};
8
9use thiserror::Error;
10
11pub mod auth;
12pub mod config;
13pub mod output;
14pub mod path;
15pub mod provider;
16pub mod repo;
17pub mod table;
18pub mod tree;
19pub mod worktree;
20
21#[derive(Debug, Error)]
22pub enum Error {
23    #[error(transparent)]
24    Repo(#[from] repo::Error),
25    #[error(transparent)]
26    Tree(#[from] tree::Error),
27    #[error("invalid regex: {}", .message)]
28    InvalidRegex { message: String },
29    #[error("Cannot detect root directory. Are you working in /?")]
30    CannotDetectRootDirectory,
31    #[error(transparent)]
32    Path(#[from] path::Error),
33}
34
35pub struct Warning(String);
36
37impl Display for Warning {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(f, "{}", self.0)
40    }
41}
42
43struct FindResult {
44    repos: Repos,
45    warnings: Vec<Warning>,
46}
47
48enum Repos {
49    InSearchRoot(repo::Repo),
50    List(Vec<repo::Repo>),
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct BranchName(String);
55
56impl fmt::Display for BranchName {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(f, "{}", self.0)
59    }
60}
61
62impl BranchName {
63    pub fn new(from: String) -> Self {
64        Self(from)
65    }
66
67    pub fn as_str(&self) -> &str {
68        &self.0
69    }
70
71    pub fn into_string(self) -> String {
72        self.0
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct RemoteName(Cow<'static, str>);
78
79impl fmt::Display for RemoteName {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "{}", self.0)
82    }
83}
84
85impl RemoteName {
86    pub fn new(from: String) -> Self {
87        Self(Cow::Owned(from))
88    }
89
90    pub const fn new_static(from: &'static str) -> Self {
91        Self(Cow::Borrowed(from))
92    }
93
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97
98    pub fn into_string(self) -> String {
99        match self.0 {
100            Cow::Borrowed(s) => s.to_owned(),
101            Cow::Owned(s) => s,
102        }
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct RemoteUrl(String);
108
109impl fmt::Display for RemoteUrl {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "{}", self.0)
112    }
113}
114
115impl RemoteUrl {
116    pub fn new(from: String) -> Self {
117        Self(from)
118    }
119
120    pub fn as_str(&self) -> &str {
121        &self.0
122    }
123
124    pub fn into_string(self) -> String {
125        self.0
126    }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct SubmoduleName(String);
131
132impl fmt::Display for SubmoduleName {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        write!(f, "{}", self.0)
135    }
136}
137
138impl SubmoduleName {
139    pub fn new(from: String) -> Self {
140        Self(from)
141    }
142
143    pub fn as_str(&self) -> &str {
144        &self.0
145    }
146
147    pub fn into_string(self) -> String {
148        self.0
149    }
150}
151
152/// Find all git repositories under root, recursively
153fn find_repos(root: &Path, exclusion_pattern: Option<&regex::Regex>) -> Result<FindResult, Error> {
154    let mut repos: Vec<repo::Repo> = Vec::new();
155    let mut repo_in_root = false;
156    let mut warnings = Vec::new();
157
158    for path in tree::find_repo_paths(root)? {
159        if exclusion_pattern
160            .as_ref()
161            .map(|regex| -> Result<bool, Error> {
162                Ok(regex.is_match(&path::path_as_string(&path)?))
163            })
164            .transpose()?
165            .unwrap_or(false)
166        {
167            warnings.push(Warning(format!(
168                "[skipped] {}",
169                &path::path_as_string(&path)?
170            )));
171            continue;
172        }
173
174        let is_worktree = repo::RepoHandle::detect_worktree(&path);
175        if path == root {
176            repo_in_root = true;
177        }
178
179        match repo::RepoHandle::open(&path, is_worktree) {
180            Err(error) => {
181                warnings.push(Warning(format!(
182                    "Error opening repo {}{}: {}",
183                    path.display(),
184                    if is_worktree { " as worktree" } else { "" },
185                    error
186                )));
187            }
188            Ok(repo) => {
189                let remotes = match repo.remotes() {
190                    Ok(remote) => remote,
191                    Err(error) => {
192                        warnings.push(Warning(format!(
193                            "{}: Error getting remotes: {}",
194                            &path::path_as_string(&path)?,
195                            error
196                        )));
197                        continue;
198                    }
199                };
200
201                let mut results: Vec<repo::Remote> = Vec::new();
202                for remote_name in remotes {
203                    match repo.find_remote(&remote_name)? {
204                        Some(remote) => {
205                            let name = remote.name()?;
206                            let url = remote.url()?;
207                            let remote_type = match repo::detect_remote_type(&url) {
208                                Ok(t) => t,
209                                Err(e) => {
210                                    warnings.push(Warning(format!(
211                                        "{}: Could not handle URL {}. Reason: {}",
212                                        &path::path_as_string(&path)?,
213                                        &url,
214                                        e
215                                    )));
216                                    continue;
217                                }
218                            };
219
220                            results.push(repo::Remote {
221                                name,
222                                url,
223                                remote_type,
224                            });
225                        }
226                        None => {
227                            warnings.push(Warning(format!(
228                                "{}: Remote {} not found",
229                                &path::path_as_string(&path)?,
230                                remote_name
231                            )));
232                        }
233                    }
234                }
235                let remotes = results;
236
237                let (namespace, name) = if path == root {
238                    (
239                        None,
240                        if let Some(parent) = root.parent() {
241                            path::path_as_string(
242                                path.strip_prefix(parent)
243                                    .expect("checked for prefix explicitly above"),
244                            )?
245                        } else {
246                            warnings.push(Warning(String::from("Getting name of the search root failed. Do you have a git repository in \"/\"?")));
247                            continue;
248                        },
249                    )
250                } else {
251                    let name = path
252                        .strip_prefix(root)
253                        .expect("checked for prefix explicitly above");
254                    let namespace = name.parent().expect("path always has a parent");
255                    (
256                        if namespace != Path::new("") {
257                            Some(path::path_as_string(namespace)?.clone())
258                        } else {
259                            None
260                        },
261                        path::path_as_string(name)?,
262                    )
263                };
264
265                repos.push(repo::Repo {
266                    name: repo::ProjectName::new(name),
267                    namespace: namespace.map(repo::ProjectNamespace::new),
268                    remotes,
269                    worktree_setup: is_worktree,
270                });
271            }
272        }
273    }
274    Ok(FindResult {
275        repos: if repo_in_root {
276            #[expect(clippy::panic, reason = "potential bug")]
277            Repos::InSearchRoot(if repos.len() != 1 {
278                panic!("found multiple repos in root?")
279            } else {
280                repos
281                    .pop()
282                    .expect("checked len() above and list cannot be empty")
283            })
284        } else {
285            Repos::List(repos)
286        },
287        warnings,
288    })
289}
290
291pub fn find_in_tree(
292    path: &Path,
293    exclusion_pattern: Option<&regex::Regex>,
294) -> Result<(tree::Tree, Vec<Warning>), Error> {
295    let mut warnings = Vec::new();
296
297    let mut result = find_repos(path, exclusion_pattern)?;
298
299    warnings.append(&mut result.warnings);
300
301    let (root, repos) = match result.repos {
302        Repos::InSearchRoot(repo) => (
303            path.parent()
304                .ok_or(Error::CannotDetectRootDirectory)?
305                .to_path_buf(),
306            vec![repo],
307        ),
308        Repos::List(repos) => (path.to_path_buf(), repos),
309    };
310
311    Ok((
312        tree::Tree {
313            root: tree::Root::new(root),
314            repos,
315        },
316        warnings,
317    ))
318}