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::setup_ssh_env;
11
12use anyhow::{bail, Context, Result};
13use serde_json::json;
14use std::ffi::OsString;
15use std::fs;
16use std::path::{Path, PathBuf};
17use tokio::process::Command;
18use tracing::{info, warn};
19
20use crate::errors::{CloneError, FileError, HttpsAccessError};
21use crate::types::{CredentialKind, Credentials, GitRoot};
22use https::HttpsAccess;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum CloneShape {
27 SingleBranch,
29 Mirror,
31 BareHeads,
34}
35
36pub struct CloneOpts {
37 pub group_name: String,
38 pub nickname: String,
39 pub repo_url: String,
40 pub branch: String,
41 pub credentials: Option<Credentials>,
42 pub follow_redirects: bool,
43 pub skip_existing: bool,
44 pub shape: CloneShape,
45}
46
47impl CloneOpts {
48 #[must_use]
49 pub fn from_root(
50 group: &str,
51 root: &GitRoot,
52 credentials: Option<Credentials>,
53 skip_existing: bool,
54 shape: CloneShape,
55 ) -> Self {
56 Self {
57 group_name: group.to_owned(),
58 nickname: root.nickname.clone(),
59 repo_url: root.url.clone(),
60 branch: root.branch.clone(),
61 credentials,
62 follow_redirects: root.follow_redirects(),
63 skip_existing,
64 shape,
65 }
66 }
67}
68
69async fn dispatch_clone(opts: &CloneOpts, dest: &Path) -> Result<()> {
70 match &opts.credentials {
71 Some(Credentials {
72 kind: CredentialKind::Ssh { .. },
73 }) => ssh::clone_ssh(opts, dest).await,
74 Some(Credentials {
75 kind: CredentialKind::Https { .. },
76 }) => https::clone_https(opts, dest).await,
77 Some(Credentials {
78 kind: CredentialKind::Token { .. },
79 }) => https::clone_https_token(opts, dest).await,
80 Some(Credentials {
81 kind: CredentialKind::AwsRole { .. },
82 }) => codecommit::clone_codecommit(opts, dest).await,
83 None if opts.repo_url.starts_with("http") => https::clone_https_public(opts, dest).await,
84 _ => bail!("no suitable credentials found for root {:?}", opts.nickname),
85 }
86}
87
88pub async fn clone_root(opts: &CloneOpts) -> Result<()> {
89 let repo_dir = PathBuf::from("groups")
90 .join(&opts.group_name)
91 .join(&opts.nickname);
92
93 if opts.skip_existing {
94 if let Ok(entries) = fs::read_dir(&repo_dir) {
95 if entries.count() > 0 {
96 info!(nickname = opts.nickname, "skipping existing repo");
97 return Ok(());
98 }
99 }
100 }
101
102 let tmp_parent = repo_dir.parent().unwrap_or_else(|| Path::new("."));
104 fs::create_dir_all(tmp_parent)
105 .with_context(|| format!("creating parent dir {}", tmp_parent.display()))?;
106 let tmp_dir = tempfile::tempdir_in(tmp_parent).context("creating temp dir")?;
107 let clone_dest = tmp_dir.path().join(&opts.nickname);
108
109 dispatch_clone(opts, &clone_dest)
110 .await
111 .with_context(|| format!("cloning {:?}", opts.nickname))?;
112
113 if opts.shape == CloneShape::Mirror {
114 convert_bare_to_worktree(&clone_dest)
115 .await
116 .with_context(|| format!("converting mirror to worktree for {:?}", opts.nickname))?;
117 }
118
119 if repo_dir.exists() {
120 fs::remove_dir_all(&repo_dir)
121 .with_context(|| format!("removing old repo dir {}", repo_dir.display()))?;
122 }
123 fs::rename(&clone_dest, &repo_dir)
124 .with_context(|| format!("moving cloned repo to {}", repo_dir.display()))?;
125
126 log_clone_result(&repo_dir, opts).await;
127
128 if opts.shape == CloneShape::Mirror {
129 if let Err(e) = write_mirror_info(&repo_dir, opts) {
130 warn!(error = %e, "failed to write .info.json");
131 }
132 }
133
134 Ok(())
135}
136
137async fn convert_bare_to_worktree(clone_dest: &Path) -> Result<()> {
138 let bare_tmp = clone_dest.with_extension("bare");
139 fs::rename(clone_dest, &bare_tmp)
140 .with_context(|| format!("renaming bare repo to {}", bare_tmp.display()))?;
141 fs::create_dir_all(clone_dest)
142 .with_context(|| format!("creating worktree dir {}", clone_dest.display()))?;
143 let git_dir = clone_dest.join(".git");
144 fs::rename(&bare_tmp, &git_dir)
145 .with_context(|| format!("moving bare contents to {}", git_dir.display()))?;
146
147 let dest_str = clone_dest.to_string_lossy().to_string();
148
149 let config_out = Command::new("git")
150 .args(["-C", &dest_str, "config", "core.bare", "false"])
151 .output()
152 .await
153 .context("running git config core.bare false")?;
154 if !config_out.status.success() {
155 bail!(
156 "git config core.bare false failed: {}",
157 String::from_utf8_lossy(&config_out.stderr)
158 );
159 }
160
161 let reset_out = Command::new("git")
162 .args(["-C", &dest_str, "reset", "--hard", "HEAD"])
163 .output()
164 .await
165 .context("running git reset --hard HEAD")?;
166 if !reset_out.status.success() {
167 bail!(
168 "git reset --hard HEAD failed: {}",
169 String::from_utf8_lossy(&reset_out.stderr)
170 );
171 }
172
173 Ok(())
174}
175
176async fn log_clone_result(repo_dir: &Path, opts: &CloneOpts) {
177 let label = match opts.shape {
178 CloneShape::Mirror => "cloned (mirror)",
179 CloneShape::BareHeads => "cloned (bare heads)",
180 CloneShape::SingleBranch => "cloned",
181 };
182 let repo_dir_str = repo_dir.to_string_lossy().to_string();
183 match Command::new("git")
184 .args(["-C", &repo_dir_str, "log", "-1", "--format=%H %ai"])
185 .output()
186 .await
187 {
188 Ok(output) if output.status.success() => {
189 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
190 info!(nickname = opts.nickname, head = head, label);
191 }
192 _ => {
193 info!(nickname = opts.nickname, dir = %repo_dir.display(), label);
194 }
195 }
196}
197
198fn write_mirror_info(repo_dir: &Path, opts: &CloneOpts) -> Result<(), FileError> {
199 let info = json!({
200 "fluid_branch": opts.branch,
201 "repo": opts.repo_url,
202 });
203 let path = repo_dir.join(".info.json");
204 fs::write(&path, format!("{info:#}")).map_err(|source| FileError {
205 action: "writing",
206 path,
207 source,
208 })
209}
210
211#[expect(
213 clippy::too_many_arguments,
214 reason = "what the python https_clone, ssh_clone and call_codecommit_clone take, the credential in one"
215)]
216pub async fn clone_to_temp(
217 temp_dir: &Path,
218 url: &str,
219 branch: &str,
220 credential: Option<&CredentialKind>,
221 follow_redirects: bool,
222 mirror: bool,
223) -> Result<PathBuf, CloneError> {
224 let nickname = uuid::Uuid::new_v4().to_string();
225 let dest = temp_dir.join(&nickname);
226 let opts = CloneOpts {
227 group_name: String::new(),
228 nickname,
229 repo_url: url.to_owned(),
230 branch: branch.to_owned(),
231 credentials: None,
232 follow_redirects,
233 skip_existing: false,
234 shape: if mirror {
235 CloneShape::Mirror
236 } else {
237 CloneShape::SingleBranch
238 },
239 };
240 let cloned = match credential {
241 Some(CredentialKind::Ssh { key }) => ssh::run_git_clone(&opts, &dest, key).await,
242 Some(CredentialKind::AwsRole {
243 arn,
244 external_id,
245 via,
246 }) => codecommit::run_git_clone(&opts, &dest, arn, external_id, via.as_deref()).await,
247 None if !has_http_scheme(url) => return Err(HttpsAccessError::NotHttp.into()),
249 _ => {
250 let access = HttpsAccess::new(url, credential)?;
251 Ok(https::run_git_clone(&opts, &dest, &access).await?)
252 }
253 };
254 cloned.inspect_err(|_| {
255 let _ = fs::remove_dir_all(&dest);
256 })?;
257 log_clone_result(&dest, &opts).await;
258 if mirror {
259 keep_with_info(&dest, &opts)?;
260 }
261 Ok(dest)
262}
263
264pub(crate) fn has_http_scheme(url: &str) -> bool {
265 url.split_once("://")
266 .is_some_and(|(scheme, _)| matches!(scheme, "http" | "https"))
267}
268
269fn keep_with_info(dest: &Path, opts: &CloneOpts) -> Result<(), CloneError> {
270 write_mirror_info(dest, opts).map_err(|error| {
272 let _ = fs::remove_dir_all(dest);
273 error.into()
274 })
275}
276
277#[must_use]
278pub fn build_clone_args(
279 opts: &CloneOpts,
280 repo_url: &str,
281 dest: &Path,
282 extra_config: &[String],
283) -> Vec<OsString> {
284 let mut args = vec!["-c".to_owned(), "core.symlinks=false".to_owned()];
285
286 args.extend(extra_config.iter().cloned());
287
288 args.push("clone".to_owned());
289
290 match opts.shape {
291 CloneShape::Mirror => args.push("--mirror".to_owned()),
292 CloneShape::BareHeads => args.extend(["--bare".to_owned(), "--no-tags".to_owned()]),
296 CloneShape::SingleBranch => args.extend([
298 "--branch".to_owned(),
299 opts.branch.clone(),
300 "--single-branch".to_owned(),
301 ]),
302 }
303
304 args.extend(["--".to_owned(), repo_url.to_owned()]);
305
306 let mut args: Vec<OsString> = args.into_iter().map(OsString::from).collect();
307 args.push(dest.as_os_str().to_owned());
309 args
310}
311
312pub(crate) fn redirect_config(follow_redirects: bool) -> [String; 2] {
313 [
314 "-c".to_owned(),
315 format!("http.followRedirects={follow_redirects}"),
316 ]
317}
318
319pub(crate) fn stall_config() -> [String; 4] {
320 [
321 "-c".to_owned(),
322 "http.lowSpeedLimit=1000".to_owned(),
323 "-c".to_owned(),
324 "http.lowSpeedTime=600".to_owned(),
325 ]
326}
327
328#[cfg(test)]
329mod tests {
330 use std::ffi::{OsStr, OsString};
331 use std::io::{BufRead, BufReader, Write};
332 use std::net::TcpListener;
333 use std::os::unix::ffi::OsStrExt;
334 use std::path::Path;
335 use std::process::Command;
336
337 use super::{build_clone_args, clone_to_temp, keep_with_info, CloneOpts, CloneShape};
338 use crate::errors::{CloneError, HttpsAccessError};
339
340 pub(super) fn opts(shape: CloneShape) -> CloneOpts {
341 CloneOpts {
342 group_name: "a-group".to_owned(),
343 nickname: "a_root".to_owned(),
344 repo_url: "https://gitlab.com/org/repo".to_owned(),
345 branch: "trunk".to_owned(),
346 credentials: None,
347 follow_redirects: false,
348 skip_existing: false,
349 shape,
350 }
351 }
352
353 fn shape_args(shape: CloneShape) -> Vec<OsString> {
354 let opts = opts(shape);
355
356 build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[])
358 .iter()
359 .skip_while(|arg| *arg != "clone")
360 .skip(1)
361 .take_while(|arg| *arg != "--")
362 .cloned()
363 .collect()
364 }
365
366 fn run_git(dir: &Path, args: &[&str]) -> String {
367 let output = Command::new("git")
368 .arg("-C")
369 .arg(dir)
370 .args([
371 "-c",
372 "user.email=test@example.com",
373 "-c",
374 "user.name=Test",
375 "-c",
376 "commit.gpgsign=false",
377 "-c",
378 "tag.gpgsign=false",
379 ])
380 .args(args)
381 .output()
382 .expect("git runs");
383 assert!(
384 output.status.success(),
385 "git {args:?} failed: {}",
386 String::from_utf8_lossy(&output.stderr)
387 );
388 String::from_utf8_lossy(&output.stdout).trim().to_owned()
389 }
390
391 #[test]
392 fn a_clone_never_writes_a_symbolic_link() {
393 let opts = opts(CloneShape::SingleBranch);
394 let args = build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[]);
395
396 assert!(args
397 .windows(2)
398 .any(|pair| pair == ["-c", "core.symlinks=false"]));
399 }
400
401 #[test]
402 fn a_destination_that_is_not_utf8_reaches_git_as_its_bytes() {
403 let opts = opts(CloneShape::SingleBranch);
404 let dest = Path::new(OsStr::from_bytes(b"/tmp/caf\xe9"));
405
406 let args = build_clone_args(&opts, &opts.repo_url, dest, &[]);
407
408 assert_eq!(args.last().map(OsString::as_os_str), Some(dest.as_os_str()));
409 }
410
411 #[test]
412 fn a_branch_clone_is_single_branch() {
413 assert_eq!(
414 shape_args(CloneShape::SingleBranch),
415 ["--branch", "trunk", "--single-branch"]
416 );
417 }
418
419 #[test]
420 fn an_empty_branch_is_asked_for_instead_of_the_default_one() {
421 let opts = CloneOpts {
422 branch: String::new(),
423 ..opts(CloneShape::SingleBranch)
424 };
425 let args = build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[]);
426
427 assert!(args.windows(2).any(|pair| pair == ["--branch", ""]));
428 }
429
430 #[test]
431 fn a_mirror_clone_takes_every_ref() {
432 assert_eq!(shape_args(CloneShape::Mirror), ["--mirror"]);
433 }
434
435 #[test]
436 fn a_bare_heads_clone_takes_heads_without_tags() {
437 assert_eq!(shape_args(CloneShape::BareHeads), ["--bare", "--no-tags"]);
438 }
439
440 #[test]
441 fn a_bare_heads_clone_holds_exactly_the_branch_heads() {
442 let tmp = tempfile::tempdir().expect("tempdir");
443 let source = tmp.path().join("source");
444 std::fs::create_dir(&source).expect("mkdir");
445 run_git(&source, &["init", "--initial-branch=main"]);
446 std::fs::write(source.join("file.txt"), "content\n").expect("write");
447 run_git(&source, &["add", "file.txt"]);
448 run_git(&source, &["commit", "-m", "first"]);
449 run_git(&source, &["branch", "feature"]);
450 run_git(&source, &["tag", "v1"]);
451 run_git(&source, &["update-ref", "refs/pull/1/head", "HEAD"]);
453
454 let dest = tmp.path().join("dest");
455 let opts = opts(CloneShape::BareHeads);
456 let args = build_clone_args(&opts, &source.to_string_lossy(), &dest, &[]);
457 let clone = Command::new("git").args(&args).output().expect("git runs");
458 assert!(
459 clone.status.success(),
460 "clone failed: {}",
461 String::from_utf8_lossy(&clone.stderr)
462 );
463
464 let refs = run_git(&dest, &["for-each-ref", "--format=%(refname)"]);
465 let mut held: Vec<&str> = refs.lines().collect();
466 held.sort_unstable();
467 assert_eq!(held, ["refs/heads/feature", "refs/heads/main"]);
468 assert_eq!(run_git(&dest, &["symbolic-ref", "HEAD"]), "refs/heads/main");
469 assert_eq!(run_git(&dest, &["config", "core.bare"]), "true");
470 for key in ["remote.origin.mirror", "remote.origin.fetch"] {
472 let lookup = Command::new("git")
473 .args(["-C", &dest.to_string_lossy(), "config", "--get", key])
474 .output()
475 .expect("git runs");
476 assert!(!lookup.status.success(), "{key} is set");
477 }
478 }
479
480 pub(super) fn source(tmp: &Path) -> String {
481 let work = tmp.join("work");
482 std::fs::create_dir(&work).unwrap();
483 run_git(&work, &["init", "--quiet", "--initial-branch=main"]);
484 run_git(&work, &["commit", "--quiet", "--allow-empty", "-m", "one"]);
485 format!("file://{}", work.display())
486 }
487
488 fn served(tmp: &Path) -> String {
490 let bare = tmp.join("served.git");
491 let cloned = Command::new("git")
492 .args(["clone", "--quiet", "--bare", &source(tmp)])
493 .arg(&bare)
494 .status()
495 .unwrap();
496 assert!(cloned.success());
497 run_git(&bare, &["update-server-info"]);
498 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
499 let port = listener.local_addr().unwrap().port();
500 std::thread::spawn(move || {
501 for mut stream in listener.incoming().flatten() {
502 let mut lines = BufReader::new(&stream).lines();
503 let request = lines.next().and_then(Result::ok).unwrap_or_default();
504 lines
506 .map_while(Result::ok)
507 .take_while(|line| !line.is_empty())
508 .for_each(drop);
509 let _ = stream.write_all(&file_answer(&bare, &request));
510 }
511 });
512 format!("http://127.0.0.1:{port}/")
513 }
514
515 fn file_answer(root: &Path, request: &str) -> Vec<u8> {
517 let path = request.split(' ').nth(1).unwrap_or_default();
518 let file = path.split('?').next().unwrap_or_default();
519 std::fs::read(root.join(file.trim_start_matches('/'))).map_or_else(
520 |_| {
521 b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
522 },
523 |body| {
524 let head = format!(
525 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
526 body.len()
527 );
528 [head.into_bytes(), body].concat()
529 },
530 )
531 }
532
533 #[tokio::test]
534 async fn git_creates_a_missing_temp_dir() {
535 let tmp = tempfile::tempdir().unwrap();
536 let url = served(tmp.path());
537 let temp_dir = tmp.path().join("missing/clones");
538
539 let folder = clone_to_temp(&temp_dir, &url, "main", None, false, false)
540 .await
541 .unwrap();
542
543 assert_eq!(folder.parent(), Some(temp_dir.as_path()));
544 }
545
546 #[tokio::test]
547 async fn a_url_without_a_credential_goes_to_git_as_given() {
548 let tmp = tempfile::tempdir().unwrap();
549 let url = served(tmp.path());
550
551 let folder = clone_to_temp(tmp.path(), &url, "main", None, false, false)
552 .await
553 .unwrap();
554
555 assert!(folder.join(".git").is_dir());
556 }
557
558 #[tokio::test]
559 async fn a_url_that_is_not_http_reaches_no_git_without_a_credential() {
560 let tmp = tempfile::tempdir().unwrap();
561 let url = source(tmp.path());
563 let temp_dir = tmp.path().join("clones");
564
565 let outcome = clone_to_temp(&temp_dir, &url, "main", None, false, false).await;
566
567 assert!(
568 matches!(outcome, Err(CloneError::Access(HttpsAccessError::NotHttp))),
569 "{outcome:?}"
570 );
571 assert!(!temp_dir.exists());
572 }
573
574 #[test]
575 fn a_mirror_whose_info_cannot_be_written_is_removed() {
576 let tmp = tempfile::tempdir().unwrap();
577 let dest = tmp.path().join("mirror");
578 std::fs::create_dir_all(dest.join(".info.json")).unwrap();
580
581 let outcome = keep_with_info(&dest, &opts(CloneShape::Mirror));
582
583 assert!(matches!(outcome, Err(CloneError::Info(_))), "{outcome:?}");
584 assert!(!dest.exists());
585 }
586}