pub mod codecommit;
pub mod https;
pub mod ssh;
pub use https::build_https_config_args;
pub use https::embed_credentials_in_url;
pub use https::format_https_url;
pub use ssh::normalize_ssh_url;
pub use ssh::setup_ssh_env;
pub use ssh::url_has_port;
use anyhow::{bail, Context, Result};
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::{info, warn};
use crate::git::types::{CredentialKind, Credentials, GitRoot};
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 mirror: bool,
}
impl CloneOpts {
pub fn from_root(
group: &str,
root: &GitRoot,
credentials: Option<Credentials>,
skip_existing: bool,
mirror: bool,
) -> Self {
Self {
group_name: group.to_string(),
nickname: root.nickname.clone(),
repo_url: root.url.clone(),
branch: root.branch.clone(),
credentials,
follow_redirects: root.follow_redirects(),
skip_existing,
mirror,
}
}
}
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(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);
let result = match &opts.credentials {
Some(Credentials {
kind: CredentialKind::Ssh { .. },
}) => ssh::clone_ssh(opts, &clone_dest).await,
Some(Credentials {
kind: CredentialKind::Https { .. },
}) => https::clone_https(opts, &clone_dest).await,
Some(Credentials {
kind: CredentialKind::Token { .. },
}) => https::clone_https_token(opts, &clone_dest).await,
Some(Credentials {
kind: CredentialKind::AwsRole { .. },
}) => codecommit::clone_codecommit(opts, &clone_dest).await,
None if opts.repo_url.starts_with("http") => {
https::clone_https_public(opts, &clone_dest).await
}
_ => bail!("no suitable credentials found for root {:?}", opts.nickname,),
};
result.with_context(|| format!("cloning {:?}", opts.nickname))?;
if opts.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.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 = if opts.mirror {
"cloned (mirror)"
} else {
"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_string();
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<()> {
let info = json!({
"fluid_branch": opts.branch,
"repo": opts.repo_url,
});
let data = serde_json::to_string_pretty(&info)?;
fs::write(repo_dir.join(".info.json"), data)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn clone_to_temp(
repo_url: &str,
repo_branch: &str,
temp_dir: &str,
credentials: Option<Credentials>,
follow_redirects: bool,
mirror: bool,
) -> (Option<String>, Option<String>) {
let nickname = uuid::Uuid::new_v4().to_string();
let dest = PathBuf::from(temp_dir).join(&nickname);
if let Err(e) = fs::create_dir_all(temp_dir) {
return (None, Some(e.to_string()));
}
let opts = CloneOpts {
group_name: String::new(),
nickname: nickname.clone(),
repo_url: repo_url.to_string(),
branch: repo_branch.to_string(),
credentials,
follow_redirects,
skip_existing: false,
mirror,
};
let 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,
_ => Err(anyhow::anyhow!("no suitable credentials")),
};
match result {
Ok(()) => {
if opts.mirror {
if let Err(e) = convert_bare_to_worktree(&dest).await {
let _ = fs::remove_dir_all(&dest);
return (None, Some(e.to_string()));
}
}
log_clone_result(&dest, &opts).await;
if opts.mirror {
if let Err(e) = write_mirror_info(&dest, &opts) {
warn!(error = %e, "failed to write .info.json");
}
}
(Some(dest.to_string_lossy().to_string()), None)
}
Err(e) => {
let _ = fs::remove_dir_all(&dest);
(None, Some(e.to_string()))
}
}
}
pub fn build_clone_args(
opts: &CloneOpts,
repo_url: &str,
dest: &Path,
extra_config: &[String],
) -> Vec<String> {
let mut args = Vec::new();
if opts.follow_redirects {
args.extend(["-c".to_string(), "http.followRedirects=true".to_string()]);
}
args.extend(extra_config.iter().cloned());
args.push("clone".to_string());
if opts.mirror {
args.push("--mirror".to_string());
} else if !opts.branch.is_empty() {
args.extend([
"--branch".to_string(),
opts.branch.clone(),
"--single-branch".to_string(),
]);
}
args.extend([
"--".to_string(),
repo_url.to_string(),
dest.to_string_lossy().to_string(),
]);
args
}