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