1pub mod codecommit;
2pub mod https;
3pub mod ssh;
4
5pub(crate) mod reason;
6
7pub use https::build_https_config_args;
8pub use https::format_https_url;
9pub use reason::hide;
10pub use ssh::normalize_ssh_url;
11pub use ssh::setup_ssh_env;
12pub use ssh::url_has_port;
13
14use anyhow::{bail, Context, Result};
15use serde_json::json;
16use std::fs;
17use std::path::{Path, PathBuf};
18use tokio::process::Command;
19use tracing::{info, warn};
20
21use crate::errors::{FileError, HttpsCloneError};
22use crate::types::{CredentialKind, Credentials, GitRoot};
23use https::HttpsAccess;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum CloneShape {
28 SingleBranch,
30 Mirror,
32 BareHeads,
35}
36
37pub struct CloneOpts {
38 pub group_name: String,
39 pub nickname: String,
40 pub repo_url: String,
41 pub branch: String,
42 pub credentials: Option<Credentials>,
43 pub follow_redirects: bool,
44 pub skip_existing: bool,
45 pub shape: CloneShape,
46}
47
48impl CloneOpts {
49 #[must_use]
50 pub fn from_root(
51 group: &str,
52 root: &GitRoot,
53 credentials: Option<Credentials>,
54 skip_existing: bool,
55 shape: CloneShape,
56 ) -> Self {
57 Self {
58 group_name: group.to_owned(),
59 nickname: root.nickname.clone(),
60 repo_url: root.url.clone(),
61 branch: root.branch.clone(),
62 credentials,
63 follow_redirects: root.follow_redirects(),
64 skip_existing,
65 shape,
66 }
67 }
68}
69
70async fn dispatch_clone(opts: &CloneOpts, dest: &Path) -> Result<()> {
71 match &opts.credentials {
72 Some(Credentials {
73 kind: CredentialKind::Ssh { .. },
74 }) => ssh::clone_ssh(opts, dest).await,
75 Some(Credentials {
76 kind: CredentialKind::Https { .. },
77 }) => https::clone_https(opts, dest).await,
78 Some(Credentials {
79 kind: CredentialKind::Token { .. },
80 }) => https::clone_https_token(opts, dest).await,
81 Some(Credentials {
82 kind: CredentialKind::AwsRole { .. },
83 }) => codecommit::clone_codecommit(opts, dest).await,
84 None if opts.repo_url.starts_with("http") => https::clone_https_public(opts, dest).await,
85 _ => bail!("no suitable credentials found for root {:?}", opts.nickname),
86 }
87}
88
89pub async fn clone_root(opts: &CloneOpts) -> Result<()> {
90 let repo_dir = PathBuf::from("groups")
91 .join(&opts.group_name)
92 .join(&opts.nickname);
93
94 if opts.skip_existing {
95 if let Ok(entries) = fs::read_dir(&repo_dir) {
96 if entries.count() > 0 {
97 info!(nickname = opts.nickname, "skipping existing repo");
98 return Ok(());
99 }
100 }
101 }
102
103 let tmp_parent = repo_dir.parent().unwrap_or_else(|| Path::new("."));
105 fs::create_dir_all(tmp_parent)
106 .with_context(|| format!("creating parent dir {}", tmp_parent.display()))?;
107 let tmp_dir = tempfile::tempdir_in(tmp_parent).context("creating temp dir")?;
108 let clone_dest = tmp_dir.path().join(&opts.nickname);
109
110 dispatch_clone(opts, &clone_dest)
111 .await
112 .with_context(|| format!("cloning {:?}", opts.nickname))?;
113
114 if opts.shape == CloneShape::Mirror {
115 convert_bare_to_worktree(&clone_dest)
116 .await
117 .with_context(|| format!("converting mirror to worktree for {:?}", opts.nickname))?;
118 }
119
120 if repo_dir.exists() {
121 fs::remove_dir_all(&repo_dir)
122 .with_context(|| format!("removing old repo dir {}", repo_dir.display()))?;
123 }
124 fs::rename(&clone_dest, &repo_dir)
125 .with_context(|| format!("moving cloned repo to {}", repo_dir.display()))?;
126
127 log_clone_result(&repo_dir, opts).await;
128
129 if opts.shape == CloneShape::Mirror {
130 if let Err(e) = write_mirror_info(&repo_dir, opts) {
131 warn!(error = %e, "failed to write .info.json");
132 }
133 }
134
135 Ok(())
136}
137
138async fn convert_bare_to_worktree(clone_dest: &Path) -> Result<()> {
139 let bare_tmp = clone_dest.with_extension("bare");
140 fs::rename(clone_dest, &bare_tmp)
141 .with_context(|| format!("renaming bare repo to {}", bare_tmp.display()))?;
142 fs::create_dir_all(clone_dest)
143 .with_context(|| format!("creating worktree dir {}", clone_dest.display()))?;
144 let git_dir = clone_dest.join(".git");
145 fs::rename(&bare_tmp, &git_dir)
146 .with_context(|| format!("moving bare contents to {}", git_dir.display()))?;
147
148 let dest_str = clone_dest.to_string_lossy().to_string();
149
150 let config_out = Command::new("git")
151 .args(["-C", &dest_str, "config", "core.bare", "false"])
152 .output()
153 .await
154 .context("running git config core.bare false")?;
155 if !config_out.status.success() {
156 bail!(
157 "git config core.bare false failed: {}",
158 String::from_utf8_lossy(&config_out.stderr)
159 );
160 }
161
162 let reset_out = Command::new("git")
163 .args(["-C", &dest_str, "reset", "--hard", "HEAD"])
164 .output()
165 .await
166 .context("running git reset --hard HEAD")?;
167 if !reset_out.status.success() {
168 bail!(
169 "git reset --hard HEAD failed: {}",
170 String::from_utf8_lossy(&reset_out.stderr)
171 );
172 }
173
174 Ok(())
175}
176
177async fn log_clone_result(repo_dir: &Path, opts: &CloneOpts) {
178 let label = match opts.shape {
179 CloneShape::Mirror => "cloned (mirror)",
180 CloneShape::BareHeads => "cloned (bare heads)",
181 CloneShape::SingleBranch => "cloned",
182 };
183 let repo_dir_str = repo_dir.to_string_lossy().to_string();
184 match Command::new("git")
185 .args(["-C", &repo_dir_str, "log", "-1", "--format=%H %ai"])
186 .output()
187 .await
188 {
189 Ok(output) if output.status.success() => {
190 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
191 info!(nickname = opts.nickname, head = head, label);
192 }
193 _ => {
194 info!(nickname = opts.nickname, dir = %repo_dir.display(), label);
195 }
196 }
197}
198
199fn write_mirror_info(repo_dir: &Path, opts: &CloneOpts) -> Result<(), FileError> {
200 let info = json!({
201 "fluid_branch": opts.branch,
202 "repo": opts.repo_url,
203 });
204 let path = repo_dir.join(".info.json");
205 fs::write(&path, format!("{info:#}")).map_err(|source| FileError {
206 action: "writing",
207 path,
208 source,
209 })
210}
211
212#[expect(
220 clippy::too_many_arguments,
221 reason = "what the python https_clone takes, its credential in one"
222)]
223pub async fn clone_to_temp(
224 temp_dir: &Path,
225 url: &str,
226 branch: &str,
227 credential: Option<&CredentialKind>,
228 follow_redirects: bool,
229 mirror: bool,
230) -> Result<PathBuf, HttpsCloneError> {
231 let access = HttpsAccess::new(url, credential)?;
232 let nickname = uuid::Uuid::new_v4().to_string();
233 let dest = temp_dir.join(&nickname);
234 let opts = CloneOpts {
235 group_name: String::new(),
236 nickname,
237 repo_url: url.to_owned(),
238 branch: branch.to_owned(),
239 credentials: None,
240 follow_redirects,
241 skip_existing: false,
242 shape: if mirror {
243 CloneShape::Mirror
244 } else {
245 CloneShape::SingleBranch
246 },
247 };
248 https::run_git_clone(&opts, &dest, &access)
249 .await
250 .inspect_err(|_| {
251 let _ = fs::remove_dir_all(&dest);
252 })?;
253 log_clone_result(&dest, &opts).await;
254 if mirror {
255 keep_with_info(&dest, &opts)?;
256 }
257 Ok(dest)
258}
259
260fn keep_with_info(dest: &Path, opts: &CloneOpts) -> Result<(), HttpsCloneError> {
261 write_mirror_info(dest, opts).map_err(|error| {
262 let _ = fs::remove_dir_all(dest);
263 error.into()
264 })
265}
266
267#[must_use]
269pub fn build_clone_args(
270 opts: &CloneOpts,
271 repo_url: &str,
272 dest: &Path,
273 extra_config: &[String],
274) -> Vec<String> {
275 let mut args = vec![
276 "-c".to_owned(),
277 "core.symlinks=false".to_owned(),
278 "-c".to_owned(),
279 format!("http.followRedirects={}", opts.follow_redirects),
280 ];
281
282 args.extend(extra_config.iter().cloned());
283
284 args.push("clone".to_owned());
285
286 match opts.shape {
287 CloneShape::Mirror => args.push("--mirror".to_owned()),
288 CloneShape::BareHeads => args.extend(["--bare".to_owned(), "--no-tags".to_owned()]),
292 CloneShape::SingleBranch => args.extend([
294 "--branch".to_owned(),
295 opts.branch.clone(),
296 "--single-branch".to_owned(),
297 ]),
298 }
299
300 args.extend([
301 "--".to_owned(),
302 repo_url.to_owned(),
303 dest.to_string_lossy().to_string(),
304 ]);
305
306 args
307}
308
309#[cfg(test)]
310mod tests {
311 use std::path::Path;
312 use std::process::Command;
313
314 use super::{build_clone_args, clone_to_temp, keep_with_info, CloneOpts, CloneShape};
315 use crate::errors::HttpsCloneError;
316
317 fn opts(shape: CloneShape) -> CloneOpts {
318 CloneOpts {
319 group_name: "a-group".to_owned(),
320 nickname: "a_root".to_owned(),
321 repo_url: "https://gitlab.com/org/repo".to_owned(),
322 branch: "trunk".to_owned(),
323 credentials: None,
324 follow_redirects: false,
325 skip_existing: false,
326 shape,
327 }
328 }
329
330 fn args_of(follow_redirects: bool) -> Vec<String> {
331 let opts = CloneOpts {
332 follow_redirects,
333 ..opts(CloneShape::SingleBranch)
334 };
335
336 build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[])
337 }
338
339 fn shape_args(shape: CloneShape) -> Vec<String> {
340 let opts = opts(shape);
341
342 build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[])
344 .iter()
345 .skip_while(|arg| *arg != "clone")
346 .skip(1)
347 .take_while(|arg| *arg != "--")
348 .cloned()
349 .collect()
350 }
351
352 fn run_git(dir: &Path, args: &[&str]) -> String {
353 let output = Command::new("git")
354 .arg("-C")
355 .arg(dir)
356 .args([
357 "-c",
358 "user.email=test@example.com",
359 "-c",
360 "user.name=Test",
361 "-c",
362 "commit.gpgsign=false",
363 "-c",
364 "tag.gpgsign=false",
365 ])
366 .args(args)
367 .output()
368 .expect("git runs");
369 assert!(
370 output.status.success(),
371 "git {args:?} failed: {}",
372 String::from_utf8_lossy(&output.stderr)
373 );
374 String::from_utf8_lossy(&output.stdout).trim().to_owned()
375 }
376
377 #[test]
378 fn a_clone_never_writes_a_symbolic_link() {
379 assert!(args_of(false).contains(&"core.symlinks=false".to_owned()));
380 assert!(args_of(true).contains(&"core.symlinks=false".to_owned()));
381 }
382
383 #[test]
384 fn a_root_that_takes_no_redirect_says_so_instead_of_leaving_it_to_git() {
385 assert!(args_of(false).contains(&"http.followRedirects=false".to_owned()));
386 }
387
388 #[test]
389 fn a_root_that_needs_a_redirect_keeps_asking_for_one() {
390 assert!(args_of(true).contains(&"http.followRedirects=true".to_owned()));
391 }
392
393 #[test]
394 fn a_branch_clone_is_single_branch() {
395 assert_eq!(
396 shape_args(CloneShape::SingleBranch),
397 ["--branch", "trunk", "--single-branch"]
398 );
399 }
400
401 #[test]
402 fn an_empty_branch_is_asked_for_instead_of_the_default_one() {
403 let opts = CloneOpts {
404 branch: String::new(),
405 ..opts(CloneShape::SingleBranch)
406 };
407 let args = build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[]);
408
409 assert!(args
410 .windows(3)
411 .any(|shape| shape == ["--branch", "", "--single-branch"]));
412 }
413
414 #[test]
415 fn a_mirror_clone_takes_every_ref() {
416 assert_eq!(shape_args(CloneShape::Mirror), ["--mirror"]);
417 }
418
419 #[test]
420 fn a_bare_heads_clone_takes_heads_without_tags() {
421 assert_eq!(shape_args(CloneShape::BareHeads), ["--bare", "--no-tags"]);
422 }
423
424 #[test]
425 fn a_bare_heads_clone_holds_exactly_the_branch_heads() {
426 let tmp = tempfile::tempdir().expect("tempdir");
427 let source = tmp.path().join("source");
428 std::fs::create_dir(&source).expect("mkdir");
429 run_git(&source, &["init", "--initial-branch=main"]);
430 std::fs::write(source.join("file.txt"), "content\n").expect("write");
431 run_git(&source, &["add", "file.txt"]);
432 run_git(&source, &["commit", "-m", "first"]);
433 run_git(&source, &["branch", "feature"]);
434 run_git(&source, &["tag", "v1"]);
435 run_git(&source, &["update-ref", "refs/pull/1/head", "HEAD"]);
437
438 let dest = tmp.path().join("dest");
439 let opts = opts(CloneShape::BareHeads);
440 let args = build_clone_args(&opts, &source.to_string_lossy(), &dest, &[]);
441 let clone = Command::new("git").args(&args).output().expect("git runs");
442 assert!(
443 clone.status.success(),
444 "clone failed: {}",
445 String::from_utf8_lossy(&clone.stderr)
446 );
447
448 let refs = run_git(&dest, &["for-each-ref", "--format=%(refname)"]);
449 let mut held: Vec<&str> = refs.lines().collect();
450 held.sort_unstable();
451 assert_eq!(held, ["refs/heads/feature", "refs/heads/main"]);
452 assert_eq!(run_git(&dest, &["symbolic-ref", "HEAD"]), "refs/heads/main");
453 assert_eq!(run_git(&dest, &["config", "core.bare"]), "true");
454 for key in ["remote.origin.mirror", "remote.origin.fetch"] {
456 let lookup = Command::new("git")
457 .args(["-C", &dest.to_string_lossy(), "config", "--get", key])
458 .output()
459 .expect("git runs");
460 assert!(!lookup.status.success(), "{key} is set");
461 }
462 }
463
464 #[tokio::test]
465 async fn a_clone_lands_under_a_temp_dir_git_creates_from_any_url_git_takes() {
466 let tmp = tempfile::tempdir().unwrap();
467 let work = tmp.path().join("work");
468 std::fs::create_dir(&work).unwrap();
469 run_git(&work, &["init", "--quiet", "--initial-branch=main"]);
470 run_git(&work, &["commit", "--quiet", "--allow-empty", "-m", "one"]);
471 let temp_dir = tmp.path().join("missing/clones");
472 let url = format!("file://{}", work.display());
473
474 let folder = clone_to_temp(&temp_dir, &url, "main", None, false, false)
475 .await
476 .unwrap();
477
478 assert_eq!(folder.parent(), Some(temp_dir.as_path()));
479 assert!(folder.join(".git").is_dir());
480 }
481
482 #[test]
483 fn a_mirror_whose_info_cannot_be_written_is_removed() {
484 let tmp = tempfile::tempdir().unwrap();
485 let dest = tmp.path().join("mirror");
486 std::fs::create_dir_all(dest.join(".info.json")).unwrap();
488 let opts = CloneOpts {
489 group_name: String::new(),
490 nickname: String::new(),
491 repo_url: "https://host/a.git".to_owned(),
492 branch: "main".to_owned(),
493 credentials: None,
494 follow_redirects: false,
495 skip_existing: false,
496 shape: CloneShape::Mirror,
497 };
498
499 let outcome = keep_with_info(&dest, &opts);
500
501 assert!(
502 matches!(outcome, Err(HttpsCloneError::Info(_))),
503 "{outcome:?}"
504 );
505 assert!(!dest.exists());
506 }
507}