#![allow(clippy::module_name_repetitions)]
use crate::repo::RepositoryBackend;
use anyhow::{anyhow, Context, Error, Result};
use rand::Rng;
use regex::bytes::Regex;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Command, Stdio};
pub struct GitShellBackend;
impl RepositoryBackend for GitShellBackend {
fn install(&self, url: &str, target: &Path, revision: Option<&str>) -> Result<()> {
git_clone(url, target, revision)
}
fn update(&self, target: &Path, url: &str, revision: Option<&str>) -> Result<()> {
git_update(target, url, revision)
}
fn is_clean(&self, target: &Path) -> Result<bool> {
git_is_working_dir_clean(target)
}
}
fn git_clone(repo_url: &str, target_dir: &Path, revision: Option<&str>) -> Result<()> {
if target_dir.exists() {
return Err(anyhow!(
"Error cloning {}. Target directory '{}' already exists",
repo_url,
target_dir.display()
));
}
let git_command = format!("git clone \"{repo_url}\" \"{}\"", target_dir.display());
let command_vec = shell_words::split(git_command.as_str()).map_err(anyhow::Error::new)?;
let Some(command) = command_vec.first() else {
return Err(anyhow!("Unable to extract cli command"));
};
let Some(args) = command_vec.get(1..) else {
return Err(anyhow!("Unable to extract cli args"));
};
Command::new(command)
.args(args)
.stdout(Stdio::null())
.status()
.with_context(|| format!("Failed to clone repository from {repo_url}"))?;
if let Some(revision_str) = revision {
let result = git_to_revision(target_dir, "origin", revision_str);
if let Err(e) = result {
fs::remove_dir_all(target_dir)
.with_context(|| format!("Failed to remove directory {}", target_dir.display()))?;
return Err(e);
}
}
Ok(())
}
fn git_update(repo_path: &Path, repo_url: &str, revision: Option<&str>) -> Result<()> {
if !repo_path.is_dir() {
return Err(anyhow!(
"Error with updating. {} is not a directory",
repo_path.display()
));
}
let tmp_remote_name = random_remote_name();
safe_command(
format!("git remote add \"{tmp_remote_name}\" \"{repo_url}\"").as_str(),
repo_path,
)?
.current_dir(repo_path)
.stdout(Stdio::null())
.status()
.with_context(|| {
format!(
"Error with adding {} as a remote named {} in {}",
repo_url,
tmp_remote_name,
repo_path.display()
)
})?;
let revision_str = revision.unwrap_or("main");
let res = git_to_revision(repo_path, &tmp_remote_name, revision_str);
if let Err(e) = res {
safe_command(
format!("git remote rm \"{tmp_remote_name}\"").as_str(),
repo_path,
)?
.stdout(Stdio::null())
.status()
.with_context(|| {
format!(
"Failed to remove temporary remote {} in {}",
tmp_remote_name,
repo_path.display()
)
})?;
return Err(e);
}
safe_command(
format!("git remote set-url origin \"{repo_url}\"").as_str(),
repo_path,
)?
.stdout(Stdio::null())
.status()
.with_context(|| {
format!(
"Failed to set origin remote to {repo_url} in {}",
repo_path.display()
)
})?;
safe_command(
format!("git remote rm \"{tmp_remote_name}\"").as_str(),
repo_path,
)?
.stdout(Stdio::null())
.status()
.with_context(|| {
format!(
"Failed to remove temporary remote {tmp_remote_name} in {}",
repo_path.display()
)
})?;
Ok(())
}
fn random_remote_name() -> String {
let mut rng = rand::thread_rng();
let random_number: u32 = rng.gen();
format!("tinty-remote-{random_number}")
}
enum RevisionType {
Tag,
Branch,
Sha,
}
struct ResolvedRevision {
sha: String,
kind: RevisionType,
}
#[allow(clippy::too_many_lines)]
fn git_resolve_revision(
repo_path: &Path,
remote_name: &str,
revision: &str,
) -> Result<ResolvedRevision> {
let expected_tag_ref = format!("refs/tags/{revision}");
let mut command = safe_command(
format!("git ls-remote --quiet --tags \"{remote_name}\" \"{expected_tag_ref}\"").as_str(),
repo_path,
)?;
let mut child = command
.stderr(Stdio::null())
.stdout(Stdio::piped())
.spawn()
.with_context(|| "Failed to spawn".to_string())?;
let Some(stdout) = child.stdout.take() else {
return Err(anyhow!("failed to capture stdout"));
};
let reader = BufReader::new(stdout);
if let Some(parts) = reader
.lines()
.map_while(Result::ok)
.map(|line| line.split('\t').map(String::from).collect::<Vec<String>>())
.filter(|parts| parts.len() == 2)
.find(|parts| {
parts
.get(1)
.map_or_else(|| false, |second_part| *second_part == expected_tag_ref)
})
{
if let Some(first_part) = parts.first() {
child.kill()?; child.wait()?;
return Ok(ResolvedRevision {
sha: first_part.clone(),
kind: RevisionType::Tag,
});
}
}
child
.wait()
.with_context(|| format!("Failed to list remote tags from {remote_name}"))?;
let expected_branch_ref = format!("refs/heads/{revision}");
let mut command = safe_command(
format!("git ls-remote --quiet \"{remote_name}\" \"{expected_branch_ref}\"").as_str(),
repo_path,
)?;
let mut child = command
.stdout(Stdio::piped())
.spawn()
.with_context(|| "Failed to spawn".to_string())?;
let Some(stdout) = child.stdout.take() else {
return Err(anyhow!("failed to capture stdout"));
};
let reader = BufReader::new(stdout);
if let Some(parts) = reader
.lines()
.map_while(Result::ok)
.map(|line| line.split('\t').map(String::from).collect::<Vec<String>>())
.filter(|parts| parts.len() == 2)
.find(|parts| {
parts
.get(1)
.map_or_else(|| false, |second_part| *second_part == expected_branch_ref)
})
{
if let Some(first_part) = parts.first() {
child.kill()?; child.wait()?;
return Ok(ResolvedRevision {
sha: first_part.clone(),
kind: RevisionType::Branch,
});
}
}
child
.wait()
.with_context(|| format!("Failed to list branches tags from {remote_name}"))?;
let pattern = r"^[0-9a-f]{1,40}$";
let Ok(re) = Regex::new(pattern) else {
return Err(anyhow!("Invalid regex"));
};
if !re.is_match(revision.as_bytes()) {
return Err(anyhow!("cannot resolve {revision} into a Git SHA1"));
}
safe_command(
format!("git fetch --quiet \"{remote_name}\"").as_str(),
repo_path,
)?
.stdout(Stdio::null())
.status()
.with_context(|| format!("unable to fetch objects from remote {remote_name}"))?;
let remote_branch_prefix = format!("refs/remotes/{remote_name}/");
let mut command = safe_command(
format!("git branch --format=\"%(refname)\" -a --contains \"{revision}\"").as_str(),
repo_path,
)?;
let mut child = command.stdout(Stdio::piped()).spawn().with_context(|| {
format!("Failed to find branches containing commit {revision} from {remote_name}")
})?;
let Some(stdout) = child.stdout.take() else {
return Err(anyhow!("failed to capture stdout"));
};
let reader = BufReader::new(stdout);
if reader
.lines()
.map_while(Result::ok)
.any(|line| line.starts_with(&remote_branch_prefix))
{
child.kill()?; child.wait()?;
return Ok(ResolvedRevision {
sha: revision.to_string(),
kind: RevisionType::Sha,
});
}
child.wait().with_context(|| {
format!("Failed to list branches from {remote_name} containing SHA1 {revision}")
})?;
Err(anyhow!(
"cannot find revision {revision} in remote {remote_name}",
))
}
fn safe_command(command_str: &str, cwd: &Path) -> Result<Command, Error> {
let command_vec = shell_words::split(command_str).map_err(anyhow::Error::new)?;
let Some(command) = command_vec.first() else {
return Err(anyhow!("Unable to extract cli command"));
};
let Some(args) = command_vec.get(1..) else {
return Err(anyhow!("Unable to extract cli args"));
};
let mut command = Command::new(command);
command.args(args).current_dir(cwd);
Ok(command)
}
fn git_to_revision(repo_path: &Path, remote_name: &str, revision: &str) -> Result<()> {
safe_command(
format!("git fetch --quiet \"{remote_name}\" \"{revision}\"").as_str(),
repo_path,
)?
.status()
.with_context(|| {
format!(
"Error with fetching revision {revision} in {}",
repo_path.display()
)
})?;
let resolved = git_resolve_revision(repo_path, remote_name, revision)?;
match resolved.kind {
RevisionType::Branch => {
safe_command(
format!(
"git checkout --quiet -B \"{revision}\" \"{}\"",
resolved.sha
)
.as_str(),
repo_path,
)?
.stdout(Stdio::null())
.current_dir(repo_path)
.status()
.with_context(|| {
format!(
"Failed to checkout branch {revision} in {}",
repo_path.display()
)
})?;
}
RevisionType::Tag | RevisionType::Sha => {
safe_command(
format!(
"git -c advice.detachedHead=false checkout --quiet \"{}\"",
resolved.sha
)
.as_str(),
repo_path,
)?
.stdout(Stdio::null())
.current_dir(repo_path)
.status()
.with_context(|| {
format!(
"Failed to checkout {} in {}",
resolved.sha,
repo_path.display()
)
})?;
}
}
Ok(())
}
fn git_is_working_dir_clean(target_dir: &Path) -> Result<bool> {
let output = safe_command("git status --porcelain", target_dir)?
.output()
.with_context(|| format!("Failed to execute process in {}", target_dir.display()))?;
Ok(output.stdout.is_empty())
}