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