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}