up_rs/generate/
git.rs

1//! Generate up config files by parsing git repositories.
2use self::GenerateGitError as E;
3use super::GENERATED_PRELUDE_COMMENT;
4use crate::opts::GenerateGitConfig;
5use crate::tasks::git::GitConfig;
6use crate::tasks::git::GitRemote;
7use crate::tasks::task::Task;
8use crate::tasks::task::TaskStatus;
9use crate::tasks::ResolveEnv;
10use crate::tasks::TaskError;
11use crate::utils::files;
12use camino::Utf8Path;
13use camino::Utf8PathBuf;
14use color_eyre::eyre::Context;
15use color_eyre::eyre::Result;
16use displaydoc::Display;
17use git2::Repository;
18use rayon::iter::Either;
19use rayon::prelude::*;
20use std::fs;
21use thiserror::Error;
22use tracing::debug;
23use tracing::error;
24use tracing::info;
25use tracing::trace;
26use walkdir::WalkDir;
27
28/// Run the up git config generation on a set of directories.
29pub fn run(configs: &[GenerateGitConfig]) -> Result<TaskStatus> {
30    let (statuses, errors): (Vec<_>, Vec<_>) =
31        configs
32            .par_iter()
33            .map(run_single)
34            .partition_map(|x| match x {
35                Ok(status) => Either::Left(status),
36                Err(e) => Either::Right(e),
37            });
38
39    if errors.is_empty() {
40        if statuses.iter().all(|s| matches!(s, TaskStatus::Skipped)) {
41            Ok(TaskStatus::Skipped)
42        } else {
43            Ok(TaskStatus::Passed)
44        }
45    } else {
46        for error in &errors {
47            error!("{error:?}");
48        }
49        let first_error = errors.into_iter().next().ok_or(E::UnexpectedNone)?;
50        Err(first_error)
51    }
52}
53
54/// Run a single git config generation.
55pub fn run_single(generate_git_config: &GenerateGitConfig) -> Result<TaskStatus> {
56    let _span =
57        tracing::info_span!("generate_git", repo = &generate_git_config.path.as_str()).entered();
58    debug!("Generating git config");
59    let mut git_task = Task::from(&generate_git_config.path)?;
60    debug!("Existing git config: {git_task:?}");
61    let name = git_task.name.as_str();
62    let mut git_configs = Vec::new();
63    let home_dir = files::home_dir()?;
64    for path in find_repos(
65        &generate_git_config.search_paths,
66        generate_git_config.excludes.as_ref(),
67    )? {
68        git_configs.push(parse_git_config(
69            &path,
70            generate_git_config.prune,
71            &generate_git_config.remote_order,
72            &home_dir,
73        )?);
74    }
75
76    git_configs.sort_unstable_by(|c1, c2| c1.path.cmp(&c2.path));
77
78    git_task.config.data = Some(serde_yaml::to_value(git_configs)?);
79
80    debug!("New git config: {git_task:?}");
81    let mut serialized_task = GENERATED_PRELUDE_COMMENT.to_owned();
82    serialized_task.push_str(&serde_yaml::to_string(&git_task.config)?);
83    trace!("New yaml file: <<<{serialized_task}>>>");
84    if serialized_task == fs::read_to_string(&generate_git_config.path)? {
85        info!("Skipped task '{name}' as git repo layout unchanged.",);
86        return Ok(TaskStatus::Skipped);
87    }
88
89    fs::write(&generate_git_config.path, serialized_task)?;
90    info!(
91        "Git repo layout generated for task '{name}' and written to '{path}'",
92        path = generate_git_config.path
93    );
94    Ok(TaskStatus::Passed)
95}
96
97impl ResolveEnv for Vec<GenerateGitConfig> {
98    fn resolve_env<F>(&mut self, env_fn: F) -> Result<(), TaskError>
99    where
100        F: Fn(&str) -> Result<String, TaskError>,
101    {
102        for config in self.iter_mut() {
103            config.path = Utf8PathBuf::from(env_fn(config.path.as_str())?);
104
105            let mut new_search_paths = Vec::new();
106            for search_path in &config.search_paths {
107                new_search_paths.push(Utf8PathBuf::from(env_fn(search_path.as_str())?));
108            }
109            config.search_paths = new_search_paths;
110
111            if let Some(excludes) = config.excludes.as_ref() {
112                let mut new_excludes = Vec::new();
113                for exclude in excludes {
114                    new_excludes.push(env_fn(exclude)?);
115                }
116                config.excludes = Some(new_excludes);
117            }
118        }
119        Ok(())
120    }
121}
122
123/// Find repositories in a set of search paths.
124fn find_repos(
125    search_paths: &[Utf8PathBuf],
126    excludes: Option<&Vec<String>>,
127) -> Result<Vec<Utf8PathBuf>> {
128    let mut repo_paths = Vec::new();
129    for path in search_paths {
130        trace!("Searching in '{path}'");
131
132        let mut it = WalkDir::new(path).into_iter();
133        'walkdir: loop {
134            let entry = match it.next() {
135                None => break,
136                Some(Err(_)) => continue,
137                Some(Ok(entry)) => entry,
138            };
139
140            // Exclude anything from the excludes list.
141            if let Some(ex) = excludes {
142                let s = entry.path().to_str().unwrap_or("");
143                for exclude in ex {
144                    if s.contains(exclude) {
145                        // Hit an exclude dir, stop iterating.
146                        it.skip_current_dir();
147                        continue 'walkdir;
148                    }
149                }
150            }
151
152            // Add anything that has a .git dir inside it.
153            if entry.file_type().is_dir() && entry.path().join(".git").is_dir() {
154                // Found matching entry, add it.
155                trace!("Entry: {entry:?}");
156                repo_paths.push(Utf8PathBuf::try_from(entry.path().to_path_buf())?);
157
158                // Stop iterating, we don't want git repos inside other git repos.
159                it.skip_current_dir();
160            }
161        }
162    }
163    debug!("Found repo paths: {repo_paths:?}");
164    Ok(repo_paths)
165}
166
167/// Generate an up git config from a git repo.
168fn parse_git_config(
169    path: &Utf8Path,
170    prune: bool,
171    remote_order: &[String],
172    home_dir: &Utf8Path,
173) -> Result<GitConfig> {
174    let repo = Repository::open(path)?;
175
176    let mut sorted_remote_names = Vec::new();
177    {
178        let mut remote_names: Vec<String> = Vec::new();
179        for opt_name in &repo.remotes()? {
180            remote_names.push(opt_name.ok_or(E::InvalidUtf8)?.to_owned());
181        }
182        for order in remote_order {
183            if let Some(pos) = remote_names.iter().position(|el| el == order) {
184                sorted_remote_names.push(remote_names.remove(pos));
185            }
186        }
187        sorted_remote_names.extend(remote_names);
188    }
189
190    let mut remotes = Vec::new();
191    for name in sorted_remote_names {
192        remotes.push(GitRemote::from(
193            &repo
194                .find_remote(&name)
195                .wrap_err_with(|| E::InvalidRemote { name })?,
196        )?);
197    }
198
199    // Replace home directory in the path with ~.
200    let replaced_path = path.strip_prefix(home_dir).map_or_else(
201        |_| path.to_owned(),
202        |suffix| Utf8PathBuf::from(format!("~/{suffix}")),
203    );
204
205    let config = GitConfig {
206        path: replaced_path,
207        branch: None,
208        remotes,
209        prune,
210    };
211    trace!("Parsed GitConfig: {config:?}");
212    Ok(config)
213}
214
215#[derive(Error, Debug, Display)]
216/// Errors thrown by this file.
217pub enum GenerateGitError {
218    /// Invalid UTF-8.
219    InvalidUtf8,
220    /// Invalid remote `{name}`.
221    InvalidRemote {
222        /// Remote name.
223        name: String,
224    },
225    /// Unexpected None in option.
226    UnexpectedNone,
227}