pub mod codecommit;
pub mod https;
pub mod ssh;
pub(crate) mod reason;
pub use https::build_https_config_args;
pub use https::format_https_url;
pub use reason::hide;
pub use ssh::setup_ssh_env;
use anyhow::{bail, Context, Result};
use serde_json::json;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::{info, warn};
use crate::errors::{CloneError, FileError};
use crate::types::{CredentialKind, Credentials, GitRoot};
use https::HttpsAccess;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CloneShape {
SingleBranch,
Mirror,
BareHeads,
}
pub struct CloneOpts {
pub group_name: String,
pub nickname: String,
pub repo_url: String,
pub branch: String,
pub credentials: Option<Credentials>,
pub follow_redirects: bool,
pub skip_existing: bool,
pub shape: CloneShape,
}
impl CloneOpts {
#[must_use]
pub fn from_root(
group: &str,
root: &GitRoot,
credentials: Option<Credentials>,
skip_existing: bool,
shape: CloneShape,
) -> Self {
Self {
group_name: group.to_owned(),
nickname: root.nickname.clone(),
repo_url: root.url.clone(),
branch: root.branch.clone(),
credentials,
follow_redirects: root.follow_redirects(),
skip_existing,
shape,
}
}
}
async fn dispatch_clone(opts: &CloneOpts, dest: &Path) -> Result<()> {
match &opts.credentials {
Some(Credentials {
kind: CredentialKind::Ssh { .. },
}) => ssh::clone_ssh(opts, dest).await,
Some(Credentials {
kind: CredentialKind::Https { .. },
}) => https::clone_https(opts, dest).await,
Some(Credentials {
kind: CredentialKind::Token { .. },
}) => https::clone_https_token(opts, dest).await,
Some(Credentials {
kind: CredentialKind::AwsRole { .. },
}) => codecommit::clone_codecommit(opts, dest).await,
None if opts.repo_url.starts_with("http") => https::clone_https_public(opts, dest).await,
_ => bail!("no suitable credentials found for root {:?}", opts.nickname),
}
}
pub async fn clone_root(opts: &CloneOpts) -> Result<()> {
let repo_dir = PathBuf::from("groups")
.join(&opts.group_name)
.join(&opts.nickname);
if opts.skip_existing {
if let Ok(entries) = fs::read_dir(&repo_dir) {
if entries.count() > 0 {
info!(nickname = opts.nickname, "skipping existing repo");
return Ok(());
}
}
}
let tmp_parent = repo_dir.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(tmp_parent)
.with_context(|| format!("creating parent dir {}", tmp_parent.display()))?;
let tmp_dir = tempfile::tempdir_in(tmp_parent).context("creating temp dir")?;
let clone_dest = tmp_dir.path().join(&opts.nickname);
dispatch_clone(opts, &clone_dest)
.await
.with_context(|| format!("cloning {:?}", opts.nickname))?;
if opts.shape == CloneShape::Mirror {
convert_bare_to_worktree(&clone_dest)
.await
.with_context(|| format!("converting mirror to worktree for {:?}", opts.nickname))?;
}
if repo_dir.exists() {
fs::remove_dir_all(&repo_dir)
.with_context(|| format!("removing old repo dir {}", repo_dir.display()))?;
}
fs::rename(&clone_dest, &repo_dir)
.with_context(|| format!("moving cloned repo to {}", repo_dir.display()))?;
log_clone_result(&repo_dir, opts).await;
if opts.shape == CloneShape::Mirror {
if let Err(e) = write_mirror_info(&repo_dir, opts) {
warn!(error = %e, "failed to write .info.json");
}
}
Ok(())
}
async fn convert_bare_to_worktree(clone_dest: &Path) -> Result<()> {
let bare_tmp = clone_dest.with_extension("bare");
fs::rename(clone_dest, &bare_tmp)
.with_context(|| format!("renaming bare repo to {}", bare_tmp.display()))?;
fs::create_dir_all(clone_dest)
.with_context(|| format!("creating worktree dir {}", clone_dest.display()))?;
let git_dir = clone_dest.join(".git");
fs::rename(&bare_tmp, &git_dir)
.with_context(|| format!("moving bare contents to {}", git_dir.display()))?;
let dest_str = clone_dest.to_string_lossy().to_string();
let config_out = Command::new("git")
.args(["-C", &dest_str, "config", "core.bare", "false"])
.output()
.await
.context("running git config core.bare false")?;
if !config_out.status.success() {
bail!(
"git config core.bare false failed: {}",
String::from_utf8_lossy(&config_out.stderr)
);
}
let reset_out = Command::new("git")
.args(["-C", &dest_str, "reset", "--hard", "HEAD"])
.output()
.await
.context("running git reset --hard HEAD")?;
if !reset_out.status.success() {
bail!(
"git reset --hard HEAD failed: {}",
String::from_utf8_lossy(&reset_out.stderr)
);
}
Ok(())
}
async fn log_clone_result(repo_dir: &Path, opts: &CloneOpts) {
let label = match opts.shape {
CloneShape::Mirror => "cloned (mirror)",
CloneShape::BareHeads => "cloned (bare heads)",
CloneShape::SingleBranch => "cloned",
};
let repo_dir_str = repo_dir.to_string_lossy().to_string();
match Command::new("git")
.args(["-C", &repo_dir_str, "log", "-1", "--format=%H %ai"])
.output()
.await
{
Ok(output) if output.status.success() => {
let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
info!(nickname = opts.nickname, head = head, label);
}
_ => {
info!(nickname = opts.nickname, dir = %repo_dir.display(), label);
}
}
}
fn write_mirror_info(repo_dir: &Path, opts: &CloneOpts) -> Result<(), FileError> {
let info = json!({
"fluid_branch": opts.branch,
"repo": opts.repo_url,
});
let path = repo_dir.join(".info.json");
fs::write(&path, format!("{info:#}")).map_err(|source| FileError {
action: "writing",
path,
source,
})
}
#[expect(
clippy::too_many_arguments,
reason = "what the python https_clone and ssh_clone take, the credential in one"
)]
pub async fn clone_to_temp(
temp_dir: &Path,
url: &str,
branch: &str,
credential: Option<&CredentialKind>,
follow_redirects: bool,
mirror: bool,
) -> Result<PathBuf, CloneError> {
let nickname = uuid::Uuid::new_v4().to_string();
let dest = temp_dir.join(&nickname);
let opts = CloneOpts {
group_name: String::new(),
nickname,
repo_url: url.to_owned(),
branch: branch.to_owned(),
credentials: None,
follow_redirects,
skip_existing: false,
shape: if mirror {
CloneShape::Mirror
} else {
CloneShape::SingleBranch
},
};
let cloned = if let Some(CredentialKind::Ssh { key }) = credential {
ssh::run_git_clone(&opts, &dest, key).await
} else {
let access = HttpsAccess::new(url, credential)?;
Ok(https::run_git_clone(&opts, &dest, &access).await?)
};
cloned.inspect_err(|_| {
let _ = fs::remove_dir_all(&dest);
})?;
log_clone_result(&dest, &opts).await;
if mirror {
keep_with_info(&dest, &opts)?;
}
Ok(dest)
}
fn keep_with_info(dest: &Path, opts: &CloneOpts) -> Result<(), CloneError> {
write_mirror_info(dest, opts).map_err(|error| {
let _ = fs::remove_dir_all(dest);
error.into()
})
}
#[must_use]
pub fn build_clone_args(
opts: &CloneOpts,
repo_url: &str,
dest: &Path,
extra_config: &[String],
) -> Vec<OsString> {
let mut args = vec!["-c".to_owned(), "core.symlinks=false".to_owned()];
args.extend(extra_config.iter().cloned());
args.push("clone".to_owned());
match opts.shape {
CloneShape::Mirror => args.push("--mirror".to_owned()),
CloneShape::BareHeads => args.extend(["--bare".to_owned(), "--no-tags".to_owned()]),
CloneShape::SingleBranch => args.extend([
"--branch".to_owned(),
opts.branch.clone(),
"--single-branch".to_owned(),
]),
}
args.extend(["--".to_owned(), repo_url.to_owned()]);
let mut args: Vec<OsString> = args.into_iter().map(OsString::from).collect();
args.push(dest.as_os_str().to_owned());
args
}
pub(crate) fn redirect_config(follow_redirects: bool) -> [String; 2] {
[
"-c".to_owned(),
format!("http.followRedirects={follow_redirects}"),
]
}
#[cfg(test)]
mod tests {
use std::ffi::{OsStr, OsString};
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::process::Command;
use super::{build_clone_args, clone_to_temp, keep_with_info, CloneOpts, CloneShape};
use crate::errors::CloneError;
pub(super) fn opts(shape: CloneShape) -> CloneOpts {
CloneOpts {
group_name: "a-group".to_owned(),
nickname: "a_root".to_owned(),
repo_url: "https://gitlab.com/org/repo".to_owned(),
branch: "trunk".to_owned(),
credentials: None,
follow_redirects: false,
skip_existing: false,
shape,
}
}
fn shape_args(shape: CloneShape) -> Vec<OsString> {
let opts = opts(shape);
build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[])
.iter()
.skip_while(|arg| *arg != "clone")
.skip(1)
.take_while(|arg| *arg != "--")
.cloned()
.collect()
}
fn run_git(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-C")
.arg(dir)
.args([
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"-c",
"commit.gpgsign=false",
"-c",
"tag.gpgsign=false",
])
.args(args)
.output()
.expect("git runs");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_owned()
}
#[test]
fn a_clone_never_writes_a_symbolic_link() {
let opts = opts(CloneShape::SingleBranch);
let args = build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[]);
assert!(args
.windows(2)
.any(|pair| pair == ["-c", "core.symlinks=false"]));
}
#[test]
fn a_destination_that_is_not_utf8_reaches_git_as_its_bytes() {
let opts = opts(CloneShape::SingleBranch);
let dest = Path::new(OsStr::from_bytes(b"/tmp/caf\xe9"));
let args = build_clone_args(&opts, &opts.repo_url, dest, &[]);
assert_eq!(args.last().map(OsString::as_os_str), Some(dest.as_os_str()));
}
#[test]
fn a_branch_clone_is_single_branch() {
assert_eq!(
shape_args(CloneShape::SingleBranch),
["--branch", "trunk", "--single-branch"]
);
}
#[test]
fn an_empty_branch_is_asked_for_instead_of_the_default_one() {
let opts = CloneOpts {
branch: String::new(),
..opts(CloneShape::SingleBranch)
};
let args = build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[]);
assert!(args.windows(2).any(|pair| pair == ["--branch", ""]));
}
#[test]
fn a_mirror_clone_takes_every_ref() {
assert_eq!(shape_args(CloneShape::Mirror), ["--mirror"]);
}
#[test]
fn a_bare_heads_clone_takes_heads_without_tags() {
assert_eq!(shape_args(CloneShape::BareHeads), ["--bare", "--no-tags"]);
}
#[test]
fn a_bare_heads_clone_holds_exactly_the_branch_heads() {
let tmp = tempfile::tempdir().expect("tempdir");
let source = tmp.path().join("source");
std::fs::create_dir(&source).expect("mkdir");
run_git(&source, &["init", "--initial-branch=main"]);
std::fs::write(source.join("file.txt"), "content\n").expect("write");
run_git(&source, &["add", "file.txt"]);
run_git(&source, &["commit", "-m", "first"]);
run_git(&source, &["branch", "feature"]);
run_git(&source, &["tag", "v1"]);
run_git(&source, &["update-ref", "refs/pull/1/head", "HEAD"]);
let dest = tmp.path().join("dest");
let opts = opts(CloneShape::BareHeads);
let args = build_clone_args(&opts, &source.to_string_lossy(), &dest, &[]);
let clone = Command::new("git").args(&args).output().expect("git runs");
assert!(
clone.status.success(),
"clone failed: {}",
String::from_utf8_lossy(&clone.stderr)
);
let refs = run_git(&dest, &["for-each-ref", "--format=%(refname)"]);
let mut held: Vec<&str> = refs.lines().collect();
held.sort_unstable();
assert_eq!(held, ["refs/heads/feature", "refs/heads/main"]);
assert_eq!(run_git(&dest, &["symbolic-ref", "HEAD"]), "refs/heads/main");
assert_eq!(run_git(&dest, &["config", "core.bare"]), "true");
for key in ["remote.origin.mirror", "remote.origin.fetch"] {
let lookup = Command::new("git")
.args(["-C", &dest.to_string_lossy(), "config", "--get", key])
.output()
.expect("git runs");
assert!(!lookup.status.success(), "{key} is set");
}
}
pub(super) fn source(tmp: &Path) -> String {
let work = tmp.join("work");
std::fs::create_dir(&work).unwrap();
run_git(&work, &["init", "--quiet", "--initial-branch=main"]);
run_git(&work, &["commit", "--quiet", "--allow-empty", "-m", "one"]);
format!("file://{}", work.display())
}
#[tokio::test]
async fn git_creates_a_missing_temp_dir() {
let tmp = tempfile::tempdir().unwrap();
let url = source(tmp.path());
let temp_dir = tmp.path().join("missing/clones");
let folder = clone_to_temp(&temp_dir, &url, "main", None, false, false)
.await
.unwrap();
assert_eq!(folder.parent(), Some(temp_dir.as_path()));
}
#[tokio::test]
async fn a_url_without_a_credential_goes_to_git_as_given() {
let tmp = tempfile::tempdir().unwrap();
let url = source(tmp.path());
let folder = clone_to_temp(tmp.path(), &url, "main", None, false, false)
.await
.unwrap();
assert!(folder.join(".git").is_dir());
}
#[test]
fn a_mirror_whose_info_cannot_be_written_is_removed() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("mirror");
std::fs::create_dir_all(dest.join(".info.json")).unwrap();
let outcome = keep_with_info(&dest, &opts(CloneShape::Mirror));
assert!(matches!(outcome, Err(CloneError::Info(_))), "{outcome:?}");
assert!(!dest.exists());
}
}