Skip to main content

git_mover/
utils.rs

1//! Utility functions
2use std::collections::HashSet;
3use std::{fmt::Debug, sync::Arc};
4
5use serde::{Deserialize, Serialize};
6use std::process::Stdio;
7use tokio::join;
8use tokio::process::Command;
9use tokio::time::{Duration, timeout};
10
11use crate::errors::GitMoverError;
12use crate::platform::{Platform, PlatformType};
13use crate::sync::{delete_repos, sync_repos};
14use crate::{
15    codeberg::config::CodebergConfig, config::GitMoverConfig, github::config::GithubConfig,
16    gitlab::config::GitlabConfig,
17};
18
19/// Repository information
20#[derive(Deserialize, Serialize, Debug, Default, PartialEq, Eq, Hash, Clone)]
21pub struct Repo {
22    /// Name of the repository
23    pub name: String,
24
25    /// Path of the repository
26    pub path: String,
27
28    /// Description of the repository
29    pub description: String,
30
31    /// Whether the repository is private
32    pub private: bool,
33
34    /// Whether the repository is a fork
35    pub fork: bool,
36}
37
38impl Repo {
39    /// Show the full name of the repo, including its path (if different from the name)
40    pub fn show_full_name(&self) -> String {
41        let fmt_path = if self.name == self.path {
42            String::new()
43        } else {
44            format!(" at path '{}'", self.path)
45        };
46        format!("{}{}", self.name, fmt_path)
47    }
48}
49
50/// GIT direction
51pub enum Direction {
52    /// Source platform
53    Source,
54    /// Destination platform
55    Destination,
56}
57
58/// Get a number from the user
59pub fn input_number() -> Result<usize, GitMoverError> {
60    loop {
61        match input()?.parse::<usize>() {
62            Ok(i) => return Ok(i),
63            Err(_) => {
64                println!("Invalid input");
65            }
66        }
67    }
68}
69
70/// check git access
71pub(crate) async fn check_ssh_access<S: AsRef<str>>(
72    ssh_url: S,
73) -> Result<(String, String), GitMoverError> {
74    let ssh_url = ssh_url.as_ref();
75    let result = timeout(Duration::from_secs(5), async {
76        Command::new("ssh")
77            .arg("-T")
78            .arg(ssh_url)
79            .stdin(Stdio::null())
80            .stdout(Stdio::piped())
81            .stderr(Stdio::piped())
82            .output()
83            .await
84    })
85    .await;
86
87    match result {
88        Ok(Ok(output)) => {
89            let stdout_str = str::from_utf8(&output.stdout)?.to_string();
90            let stderr_str = str::from_utf8(&output.stderr)?.to_string();
91            Ok((stdout_str, stderr_str))
92        }
93        Ok(Err(e)) => Err(e.into()),
94        Err(e) => Err(e.into()),
95    }
96}
97
98/// Get the platform to use
99pub(crate) fn get_plateform(
100    config: &mut GitMoverConfig,
101    direction: &Direction,
102) -> Result<Box<dyn Platform>, GitMoverError> {
103    let plateform_from_cli: Option<PlatformType> = match direction {
104        Direction::Source => config.cli_args.source.clone(),
105        Direction::Destination => config.cli_args.destination.clone(),
106    };
107    let chosen_platform = if let Some(platform) = plateform_from_cli {
108        platform
109    } else {
110        println!(
111            "Choose a platform {}",
112            match direction {
113                Direction::Source => "for source",
114                Direction::Destination => "for destination",
115            }
116        );
117        let platforms = [
118            PlatformType::Github,
119            PlatformType::Gitlab,
120            PlatformType::Codeberg,
121        ];
122        for (i, platform) in platforms.iter().enumerate() {
123            println!("{i}: {platform}");
124        }
125        let plateform = loop {
126            let plateform = input_number()?;
127            if platforms.get(plateform).is_none() {
128                println!("Wrong number");
129            } else {
130                break plateform;
131            }
132        };
133        platforms[plateform].clone()
134    };
135    let plateform: Box<dyn Platform> = match chosen_platform {
136        PlatformType::Gitlab => Box::new(GitlabConfig::get_plateform(config)?),
137        PlatformType::Github => Box::new(GithubConfig::get_plateform(config)?),
138        PlatformType::Codeberg => Box::new(CodebergConfig::get_plateform(config)?),
139    };
140    Ok(plateform)
141}
142
143/// Main function to sync repositories
144/// # Errors
145/// Error if an error happens
146#[allow(clippy::too_many_lines)]
147pub async fn main_sync(config: GitMoverConfig) -> Result<(), GitMoverError> {
148    let mut config = config;
149    let source_platform = get_plateform(&mut config, &Direction::Source)?;
150    println!("Chosen {} as source", source_platform.get_remote_url());
151
152    let destination_platform = get_plateform(&mut config, &Direction::Destination)?;
153    println!(
154        "Chosen {} as destination",
155        destination_platform.get_remote_url()
156    );
157    if source_platform.get_remote_url() == destination_platform.get_remote_url() {
158        return Err("Source and destination can't be the same".into());
159    }
160    println!("Checking the git access for each plateform");
161    let (acc, acc2) = join!(
162        source_platform.check_git_access(),
163        destination_platform.check_git_access()
164    );
165    match acc {
166        Ok(()) => {
167            println!("Checked access to {}", source_platform.get_remote_url());
168        }
169        Err(e) => return Err(e),
170    }
171    match acc2 {
172        Ok(()) => {
173            println!(
174                "Checked access to {}",
175                destination_platform.get_remote_url()
176            );
177        }
178        Err(e) => return Err(e),
179    }
180    let source_platform = Arc::new(source_platform);
181    let destination_platform = Arc::new(destination_platform);
182    let (repos_source, repos_destination) = join!(
183        source_platform.get_all_repos(),
184        destination_platform.get_all_repos()
185    );
186
187    let repos_source = match repos_source {
188        Ok(repos) => repos,
189        Err(e) => {
190            return Err(format!("Error getting repositories for source: {e}").into());
191        }
192    };
193
194    let repos_destination = match repos_destination {
195        Ok(repos) => repos,
196        Err(e) => {
197            return Err(format!("Error getting repositories for destination: {e}").into());
198        }
199    };
200
201    println!("-----------");
202    let repos_source_without_fork = repos_source
203        .clone()
204        .into_iter()
205        .filter(|repo| !repo.fork)
206        .collect::<Vec<_>>();
207    let repos_source_forks = repos_source
208        .clone()
209        .into_iter()
210        .filter(|repo| repo.fork)
211        .collect::<Vec<_>>();
212    let repos_dest_without_fork = repos_destination
213        .iter()
214        .filter(|repo| !repo.fork)
215        .collect::<Vec<_>>();
216    let repos_dest_forks = repos_destination
217        .iter()
218        .filter(|repo| repo.fork)
219        .collect::<Vec<_>>();
220    println!("Number of repos in source: {}", repos_source.len());
221    println!(
222        "- Number of forked repos in source: {}",
223        repos_source_forks.len()
224    );
225    println!(
226        "- Number of (non-forked) repos in source: {}",
227        repos_source_without_fork.len()
228    );
229    println!(
230        "Number of repos in destination: {}",
231        repos_destination.len()
232    );
233    println!(
234        "- Number of forked repos in source: {}",
235        repos_dest_forks.len()
236    );
237    println!(
238        "- Number of (non-forked) repos in source: {}",
239        repos_dest_without_fork.len()
240    );
241    println!("-----------");
242    // let cloned_repos_destination = repos_destination.clone();
243    let repos_source_set: HashSet<_> = repos_source_without_fork.iter().collect();
244    let repos_destination_set: HashSet<_> = repos_dest_without_fork.iter().collect();
245    let repos_to_delete: Vec<&Repo> = repos_dest_without_fork
246        .iter()
247        .filter_map(|item| {
248            if repos_source_set.contains(*item) {
249                None
250            } else {
251                Some(*item)
252            }
253        })
254        .collect();
255    let resync = config.cli_args.resync;
256    let difference: Vec<Repo> = if resync {
257        repos_source_without_fork
258    } else {
259        repos_source_without_fork
260            .into_iter()
261            .filter(|item| !repos_destination_set.contains(&item))
262            .collect()
263    };
264    println!("Number of repos to sync: {}", difference.len());
265    println!("Number of repos to delete: {}", repos_to_delete.len());
266    if !difference.is_empty() && yes_no_input("Do you want to start syncing ? (y/n)")? {
267        match sync_repos(
268            &config,
269            source_platform.clone(),
270            destination_platform.clone(),
271            difference,
272        )
273        .await
274        {
275            Ok(()) => {
276                println!("All repos synced");
277            }
278            Err(e) => return Err(format!("Error syncing repos: {e}").into()),
279        }
280    }
281    if config.cli_args.no_forks {
282        println!("Not syncing forks");
283    } else if repos_source_forks.is_empty() {
284        println!("No forks found");
285    } else if yes_no_input(format!(
286        "Do you want to sync forks ({})? (y/n)",
287        repos_source_forks.len()
288    ))? {
289        match sync_repos(
290            &config,
291            source_platform,
292            destination_platform.clone(),
293            repos_source_forks,
294        )
295        .await
296        {
297            Ok(()) => {
298                println!("All forks synced");
299            }
300            Err(e) => {
301                return Err(format!("Error syncing forks: {e}").into());
302            }
303        }
304    }
305    if config.cli_args.no_delete {
306        println!("Not prompting for deletion");
307    } else if repos_to_delete.is_empty() {
308        println!("Nothing to delete");
309    } else if yes_no_input(format!(
310        "Do you want to delete the missing ({}) repos (manually)? (y/n)",
311        repos_to_delete.len()
312    ))? {
313        match delete_repos(destination_platform, repos_to_delete).await {
314            Ok(()) => {
315                println!("All repos deleted");
316            }
317            Err(e) => {
318                return Err(format!("Error deleting repos: {e}").into());
319            }
320        }
321    }
322    Ok(())
323}
324
325/// Get input from the user
326pub(crate) fn input() -> Result<String, GitMoverError> {
327    use std::io::{Write, stdin, stdout};
328    let mut s = String::new();
329    let _ = stdout().flush();
330    stdin()
331        .read_line(&mut s)
332        .map_err(|e| GitMoverError::new_with_source("Did not enter a correct string", e))?;
333    if let Some('\n') = s.chars().next_back() {
334        s.pop();
335    }
336    if let Some('\r') = s.chars().next_back() {
337        s.pop();
338    }
339    Ok(s)
340}
341
342/// Get a yes/no input from the user
343pub(crate) fn yes_no_input<S: AsRef<str>>(msg: S) -> Result<bool, GitMoverError> {
344    let msg = msg.as_ref();
345    loop {
346        println!("{msg}");
347        let input = input()?;
348        match input.to_lowercase().as_str() {
349            "yes" | "y" | "Y" | "YES" | "Yes " => return Ok(true),
350            "no" | "n" | "N" | "NO" | "No" => return Ok(false),
351            _ => println!("Invalid input"),
352        }
353    }
354}
355
356/// Get password from the user
357pub(crate) fn get_password() -> Result<String, GitMoverError> {
358    rpassword::read_password()
359        .map_err(|e| GitMoverError::new_with_source("Error reading password", e))
360}
361
362#[cfg(test)]
363mod test {
364
365    use super::*;
366
367    #[test]
368    fn compare_repo() {
369        let repo1 = Repo {
370            name: "test".to_string(),
371            path: "test".to_string(),
372            description: "test".to_string(),
373            private: false,
374            fork: false,
375        };
376        let repo2 = Repo {
377            name: "test".to_string(),
378            path: "test".to_string(),
379            description: "test".to_string(),
380            private: false,
381            fork: false,
382        };
383        let repo3 = Repo {
384            name: "test".to_string(),
385            path: "test".to_string(),
386            description: "test".to_string(),
387            private: true,
388            fork: false,
389        };
390        assert!(repo1 == repo2);
391        assert!(repo1 != repo3);
392        assert_eq!(repo1, repo2);
393    }
394}