1use 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
28pub 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
54pub 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
123fn 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 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 it.skip_current_dir();
147 continue 'walkdir;
148 }
149 }
150 }
151
152 if entry.file_type().is_dir() && entry.path().join(".git").is_dir() {
154 trace!("Entry: {entry:?}");
156 repo_paths.push(Utf8PathBuf::try_from(entry.path().to_path_buf())?);
157
158 it.skip_current_dir();
160 }
161 }
162 }
163 debug!("Found repo paths: {repo_paths:?}");
164 Ok(repo_paths)
165}
166
167fn 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 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)]
216pub enum GenerateGitError {
218 InvalidUtf8,
220 InvalidRemote {
222 name: String,
224 },
225 UnexpectedNone,
227}