hg_git_fast_import/
git.rs

1use std::{
2    collections::HashSet,
3    fs::{self, File},
4    io::prelude::Write,
5    path::{Path, PathBuf},
6    process::{Child, Command, ExitStatus, Stdio},
7};
8
9use super::{
10    config::RepositorySavedState, env::Environment, read_file, TargetRepository,
11    TargetRepositoryError,
12};
13
14use tracing::{debug, error, info};
15
16pub const DEFAULT_BRANCH: &str = "master";
17
18pub struct StdoutTargetRepository<'a> {
19    stdoutlock: std::io::StdoutLock<'a>,
20}
21
22impl<'a> From<std::io::StdoutLock<'a>> for StdoutTargetRepository<'a> {
23    fn from(value: std::io::StdoutLock<'a>) -> Self {
24        Self { stdoutlock: value }
25    }
26}
27
28impl<'a> TargetRepository for StdoutTargetRepository<'a> {
29    fn start_import(
30        &mut self,
31        _git_active_branches: Option<usize>,
32        _default_branch: Option<&str>,
33    ) -> Result<(&mut dyn Write, Option<RepositorySavedState>, String), TargetRepositoryError> {
34        Ok((&mut self.stdoutlock, None, DEFAULT_BRANCH.to_string()))
35    }
36    fn finish(&mut self) -> Result<(), TargetRepositoryError> {
37        Ok(())
38    }
39}
40
41pub struct GitTargetRepository<'a> {
42    path: PathBuf,
43    fast_import_cmd: Option<Child>,
44    saved_state: Option<RepositorySavedState>,
45    env: Option<&'a Environment>,
46}
47
48impl<'a> GitTargetRepository<'a> {
49    pub fn open<P: AsRef<Path>>(value: P) -> Self {
50        Self {
51            path: value.as_ref().into(),
52            fast_import_cmd: None,
53            saved_state: None,
54            env: None,
55        }
56    }
57
58    pub fn set_env(&mut self, value: &'a Environment) {
59        self.env = Some(value);
60    }
61
62    pub fn path(&self) -> &Path {
63        &self.path
64    }
65
66    fn get_saved_state_path(&self) -> PathBuf {
67        let mut saved_state = self.path.join(".git").join(env!("CARGO_PKG_NAME"));
68        saved_state.set_extension("lock");
69        saved_state
70    }
71
72    pub fn create_repo(&self, default_branch: &str) -> Result<(), TargetRepositoryError> {
73        let path = &self.path;
74        info!("Creating new dir");
75        fs::create_dir_all(path)?;
76
77        info!("Init Git repo");
78        let status = Command::new("git")
79            .args(["init", "-b", default_branch])
80            .current_dir(path)
81            .status()?;
82        if !status.success() {
83            error!("Cannot init Git repo");
84            return Err(TargetRepositoryError::CannotInitRepo(status));
85        }
86
87        info!("Configure Git repo");
88        self.git_config("core.ignoreCase", "false")?;
89        self.git_config("gc.auto", "0")?;
90
91        info!("New Git repo initialization done");
92
93        Ok(())
94    }
95
96    pub fn git_config_default_branch(&self) -> Result<String, TargetRepositoryError> {
97        let output = Command::new("git")
98            .args(["config", "--global", "init.defaultBranch"])
99            .output()?;
100        let output_str = String::from_utf8_lossy(&output.stdout);
101        let default_branch = output_str.trim();
102        Ok(if !default_branch.is_empty() {
103            default_branch.into()
104        } else {
105            DEFAULT_BRANCH.into()
106        })
107    }
108
109    pub fn git_cmd(&self, args: &[&str]) -> Command {
110        let mut git_cmd = Command::new("git");
111        git_cmd.current_dir(&self.path).args(args);
112        git_cmd
113    }
114
115    fn git_config(&self, key: &str, value: &str) -> Result<(), TargetRepositoryError> {
116        let status = self.git_cmd(&["config", key, value]).status()?;
117
118        if !status.success() {
119            error!("Cannot configure Git repo");
120            return Err(TargetRepositoryError::CannotConfigRepo(status));
121        }
122
123        Ok(())
124    }
125
126    fn git(&self, args: &[&str], quiet: bool) -> ExitStatus {
127        self.git_cmd_quiet(|cmd| cmd.args(args), quiet)
128    }
129
130    fn git_cmd_quiet<F>(&self, mut f: F, quiet: bool) -> ExitStatus
131    where
132        F: FnMut(&mut Command) -> &mut Command,
133    {
134        self.git_cmd_status(|cmd| {
135            f(cmd);
136            if quiet {
137                cmd.arg("--quiet");
138            }
139        })
140    }
141
142    fn git_cmd_status<F>(&self, mut f: F) -> ExitStatus
143    where
144        F: FnMut(&mut Command),
145    {
146        let mut git_cmd = Command::new("git");
147        f(&mut git_cmd);
148        git_cmd.current_dir(&self.path).status().unwrap()
149    }
150}
151
152impl<'a> TargetRepository for GitTargetRepository<'a> {
153    fn start_import(
154        &mut self,
155        git_active_branches: Option<usize>,
156        default_branch: Option<&str>,
157    ) -> Result<(&mut dyn Write, Option<RepositorySavedState>, String), TargetRepositoryError> {
158        let path = &self.path;
159        let saved_state;
160        info!("Checking Git repo: {}", path.to_str().unwrap());
161
162        let clean = self.env.map(|x| x.clean).unwrap_or_default();
163        if path.exists() && clean {
164            info!("Path exists, removing because of clean option");
165            std::fs::remove_dir_all(path)?;
166        }
167
168        let default_branch = if let Some(default_branch) = default_branch {
169            default_branch.to_string()
170        } else {
171            self.git_config_default_branch()?
172        };
173        if path.exists() {
174            if path.is_dir() {
175                info!("Path exists, checking for saved state");
176
177                let saved_state_path = self.get_saved_state_path();
178
179                if !saved_state_path.exists() {
180                    return Err(TargetRepositoryError::SavedStateDoesNotExist);
181                }
182
183                let saved_state_str = read_file(&saved_state_path)?;
184                let loaded_saved_state: RepositorySavedState =
185                    toml::from_str(&saved_state_str).unwrap();
186
187                info!("Loaded saved state: {:?}", loaded_saved_state);
188                saved_state = Some(loaded_saved_state);
189            } else {
190                error!("Path must be directory");
191                return Err(TargetRepositoryError::IsNotDir);
192            }
193        } else {
194            self.create_repo(&default_branch)?;
195            saved_state = None;
196        }
197
198        let mut git = Command::new("git");
199        let mut git_cmd = git.args([
200            "fast-import",
201            "--export-marks=.git/hg-git-fast-import.marks",
202            "--import-marks-if-exists=.git/hg-git-fast-import.marks",
203            "--quiet",
204        ]);
205        if let Some(git_active_branches) = git_active_branches {
206            git_cmd = git_cmd.arg(format!("--active-branches={}", git_active_branches));
207        }
208        self.fast_import_cmd = Some(git_cmd.current_dir(path).stdin(Stdio::piped()).spawn()?);
209
210        Ok((
211            self.fast_import_cmd
212                .as_mut()
213                .map(|x| x.stdin.as_mut().unwrap())
214                .unwrap(),
215            saved_state,
216            default_branch,
217        ))
218    }
219
220    fn finish(&mut self) -> Result<(), TargetRepositoryError> {
221        info!("Waiting for Git fast-import to finish");
222
223        let status = self.fast_import_cmd.as_mut().unwrap().wait()?;
224        info!("Finished");
225
226        let cron = self.env.map(|x| x.cron).unwrap_or_default();
227
228        let status = if status.success() {
229            info!("Checking out HEAD revision");
230            self.git(&["checkout", "HEAD"], cron)
231        } else {
232            error!("Git fast-import failed.");
233            return Err(TargetRepositoryError::ImportFailed(status));
234        };
235
236        let status = if status.success() {
237            info!("Resetting Git repo.");
238            self.git(&["reset", "--hard"], cron)
239        } else {
240            return Err(TargetRepositoryError::GitFailure(
241                status,
242                "Cannot checkout HEAD revision in Git repo.".into(),
243            ));
244        };
245
246        let status = if status.success() {
247            info!("Cleanup Git repo");
248            self.git(&["clean", "-d", "-x", "-f"], cron)
249        } else {
250            return Err(TargetRepositoryError::GitFailure(
251                status,
252                "Cannot reset Git repo.".into(),
253            ));
254        };
255        if !status.success() {
256            return Err(TargetRepositoryError::GitFailure(
257                status,
258                "Cannot cleanup Git repo.".into(),
259            ));
260        };
261
262        let (target_push, target_pull) = self
263            .env
264            .map(|x| (x.target_push, x.target_pull))
265            .unwrap_or_default();
266
267        if target_pull {
268            info!("Pulling Git repo.");
269            self.fetch_all()?;
270        }
271
272        if target_push {
273            info!("Pushing Git repo.");
274            let status = self.git(&["push", "--all"], cron);
275            let status = if status.success() {
276                self.git(&["push", "--tags"], cron)
277            } else {
278                return Err(TargetRepositoryError::GitFailure(
279                    status,
280                    "Cannot push all to Git repo.".into(),
281                ));
282            };
283            if !status.success() {
284                return Err(TargetRepositoryError::GitFailure(
285                    status,
286                    "Cannot push all tags to Git repo.".into(),
287                ));
288            };
289        }
290
291        Ok(())
292    }
293
294    fn verify(
295        &self,
296        verified_repo: &str,
297        subfolder: Option<&str>,
298    ) -> Result<(), TargetRepositoryError> {
299        info!("Verifying...");
300
301        let path: String = subfolder.map_or_else(
302            || self.path.to_str().unwrap().into(),
303            |subfolder| self.path.join(subfolder).to_str().unwrap().into(),
304        );
305
306        info!(
307            "Verify - Mercurial (source): {} vs Git (target): {}",
308            verified_repo, path
309        );
310        let status = Command::new("diff")
311            .args([
312                "-ur",
313                "--exclude=.hg",
314                "--exclude=.idea",
315                "--exclude=.git",
316                "--exclude=*.iml",
317                "--exclude=target",
318                "--exclude=.hgtags",
319                verified_repo,
320                &path,
321            ])
322            .status()
323            .unwrap();
324        if status.success() {
325            Ok(())
326        } else {
327            Err(TargetRepositoryError::VerifyFail)
328        }
329    }
330
331    fn get_saved_state(&self) -> Option<&RepositorySavedState> {
332        self.saved_state.as_ref()
333    }
334
335    fn save_state(&self, state: RepositorySavedState) -> Result<(), TargetRepositoryError> {
336        let path = &self.path;
337        info!("Saving state to Git repo: {}", path.to_str().unwrap());
338        let saved_state_path = self.get_saved_state_path();
339        let toml = toml::to_string(&state).unwrap();
340        let mut f = File::create(saved_state_path)?;
341        f.write_all(toml.as_bytes())?;
342        Ok(())
343    }
344
345    fn remote_list(&self) -> Result<HashSet<String>, TargetRepositoryError> {
346        debug!("git remote");
347        let output = Command::new("git")
348            .arg("remote")
349            .current_dir(&self.path)
350            .output()?;
351        Ok(output
352            .stdout
353            .split(|&x| x == b'\n')
354            .filter_map(|x| {
355                if !x.is_empty() {
356                    Some(std::str::from_utf8(x).unwrap().into())
357                } else {
358                    None
359                }
360            })
361            .collect())
362    }
363
364    fn remote_add(&self, name: &str, url: &str) -> Result<(), TargetRepositoryError> {
365        debug!("git remote add {} {}", name, url);
366        Command::new("git")
367            .args(["remote", "add", name, url])
368            .current_dir(&self.path)
369            .status()?;
370        Ok(())
371    }
372
373    fn checkout(&self, branch: &str) -> Result<(), TargetRepositoryError> {
374        debug!("git checkout -B {}", branch);
375        Command::new("git")
376            .args(["checkout", "-B", branch])
377            .current_dir(&self.path)
378            .status()?;
379        Ok(())
380    }
381
382    fn merge_unrelated(&self, branches: &[&str]) -> Result<(), TargetRepositoryError> {
383        debug!(
384            "git merge -n --allow-unrelated-histories --no-edit {}",
385            branches.join(" ")
386        );
387        Command::new("git")
388            .args(["merge", "-n", "--allow-unrelated-histories", "--no-edit"])
389            .args(branches)
390            .current_dir(&self.path)
391            .status()?;
392        Ok(())
393    }
394
395    fn fetch_all(&self) -> Result<(), TargetRepositoryError> {
396        debug!("git fetch -q --all");
397        Command::new("git")
398            .args(["fetch", "-q", "--all"])
399            .current_dir(&self.path)
400            .status()?;
401
402        debug!("git fetch -q --tags");
403        Command::new("git")
404            .args(["fetch", "-q", "--tags"])
405            .current_dir(&self.path)
406            .status()?;
407        Ok(())
408    }
409}