#[cfg(windows)]
use std::fs;
use std::path::Path;
use std::process::Command;
use git2::{
Cred, CredentialType, ErrorClass, FetchOptions, RemoteCallbacks, Repository, build::RepoBuilder,
};
use crate::error::{AugentError, Result};
fn normalize_ssh_url_for_clone(url: &str) -> std::borrow::Cow<'_, str> {
if !url.starts_with("git@") || url.starts_with("ssh://") {
return std::borrow::Cow::Borrowed(url);
}
if let Some(colon_pos) = url.find(':') {
let host_part = &url[..colon_pos]; let path_part = &url[colon_pos + 1..];
let normalized_path = if path_part.starts_with('/') {
path_part.to_string()
} else {
format!("/{}", path_part)
};
return std::borrow::Cow::Owned(format!("ssh://{}{}", host_part, normalized_path));
}
std::borrow::Cow::Borrowed(url)
}
fn normalize_file_url_for_clone(url: &str) -> std::borrow::Cow<'_, str> {
if !url.starts_with("file://") {
return std::borrow::Cow::Borrowed(url);
}
#[cfg(not(windows))]
{
let after = &url[7..]; if after.contains('\\') {
let path = after.replace('\\', "/");
return std::borrow::Cow::Owned(format!("file:///{}", path));
}
if !after.is_empty() && !after.starts_with('/') {
return std::borrow::Cow::Owned(format!("file:///{}", after));
}
}
std::borrow::Cow::Borrowed(url)
}
#[cfg(windows)]
fn clone_local_file(url: &str, target: &Path) -> Result<Repository> {
let path_str = url
.strip_prefix("file:///")
.or_else(|| url.strip_prefix("file://"))
.unwrap_or(url)
.replace('|', ":");
let source = Path::new(&path_str);
if !source.is_dir() {
return Err(AugentError::GitCloneFailed {
url: url.to_string(),
reason: "local path is not a directory".to_string(),
});
}
fs::create_dir_all(target).map_err(|e| AugentError::GitCloneFailed {
url: url.to_string(),
reason: format!("Failed to create target directory: {}", e),
})?;
copy_dir_recursive_for_clone(source, target, url)?;
Repository::open(target).map_err(|e| AugentError::GitCloneFailed {
url: url.to_string(),
reason: e.message().to_string(),
})
}
#[cfg(windows)]
fn copy_dir_recursive_for_clone(src: &Path, dst: &Path, url: &str) -> Result<()> {
for entry in fs::read_dir(src).map_err(|e| AugentError::GitCloneFailed {
url: url.to_string(),
reason: format!("Failed to read source directory: {}", e),
})? {
let entry = entry.map_err(|e| AugentError::GitCloneFailed {
url: url.to_string(),
reason: format!("Failed to read directory entry: {}", e),
})?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
fs::create_dir_all(&dst_path).map_err(|e| AugentError::GitCloneFailed {
url: url.to_string(),
reason: format!("Failed to create directory: {}", e),
})?;
copy_dir_recursive_for_clone(&src_path, &dst_path, url)?;
} else {
fs::copy(&src_path, &dst_path).map_err(|e| AugentError::GitCloneFailed {
url: url.to_string(),
reason: format!(
"Failed to copy {} to {}: {}",
src_path.display(),
dst_path.display(),
e
),
})?;
}
}
Ok(())
}
fn interpret_git_error(err: &git2::Error) -> String {
let class = err.class();
let message = err.message().to_lowercase();
if message.contains("not found") || message.contains("404") {
"Repository not found".to_string()
} else if message.contains("too many redirects") || message.contains("authentication replays") {
"Repository not found".to_string()
} else if message.contains("authentication") || message.contains("credentials") {
"Authentication failed".to_string()
} else if message.contains("permission denied") || message.contains("access denied") {
"Permission denied".to_string()
} else if message.contains("connection")
|| message.contains("network")
|| message.contains("timeout")
|| message.contains("timed out")
{
"Network error".to_string()
} else if class == ErrorClass::Http {
if message.contains("certificate") {
"Certificate error".to_string()
} else if message.contains("ssl") {
"SSL error".to_string()
} else {
format!("HTTP error: {}", err.message())
}
} else if class == ErrorClass::Ssh {
format!("SSH error: {}", err.message())
} else {
err.message().to_string()
}
}
pub fn clone(url: &str, target: &Path, shallow: bool) -> Result<Repository> {
#[cfg(windows)]
if url.starts_with("file://") {
return clone_local_file(url, target);
}
let mut callbacks = RemoteCallbacks::new();
setup_auth_callbacks(&mut callbacks);
let mut fetch_options = FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
let is_local = url.starts_with("file://")
|| url.starts_with('/')
|| std::path::Path::new(url).is_absolute();
if shallow && !is_local {
fetch_options.depth(1);
}
let mut builder = RepoBuilder::new();
builder.fetch_options(fetch_options);
let url_to_clone = normalize_ssh_url_for_clone(url);
let url_to_clone = normalize_file_url_for_clone(&url_to_clone);
builder.clone(url_to_clone.as_ref(), target).map_err(|e| {
let reason = interpret_git_error(&e);
AugentError::GitCloneFailed {
url: url.to_string(),
reason,
}
})
}
pub fn ls_remote(url: &str, git_ref: Option<&str>) -> Result<String> {
let is_local =
url.starts_with("file://") || url.starts_with('/') || Path::new(url).is_absolute();
if is_local {
return Err(AugentError::GitRefResolveFailed {
git_ref: git_ref.unwrap_or("HEAD").to_string(),
reason: "ls-remote not used for local URLs".to_string(),
});
}
let ref_arg = git_ref.unwrap_or("HEAD");
let output = Command::new("git")
.args(["ls-remote", "--exit-code", url, ref_arg])
.output()
.map_err(|e| AugentError::GitRefResolveFailed {
git_ref: ref_arg.to_string(),
reason: format!("git ls-remote failed: {}", e),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AugentError::GitRefResolveFailed {
git_ref: ref_arg.to_string(),
reason: stderr.trim().to_string(),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.next()
.ok_or_else(|| AugentError::GitRefResolveFailed {
git_ref: ref_arg.to_string(),
reason: "git ls-remote returned no output".to_string(),
})?;
let sha = line
.split_whitespace()
.next()
.ok_or_else(|| AugentError::GitRefResolveFailed {
git_ref: ref_arg.to_string(),
reason: "could not parse ls-remote output".to_string(),
})?;
if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(AugentError::GitRefResolveFailed {
git_ref: ref_arg.to_string(),
reason: format!("invalid SHA from ls-remote: {}", sha),
});
}
Ok(sha.to_string())
}
pub fn resolve_ref(repo: &Repository, git_ref: Option<&str>) -> Result<String> {
let reference = match git_ref {
Some(r) => {
resolve_reference(repo, r)?
}
None => {
repo.head()
.map_err(|e| AugentError::GitRefResolveFailed {
git_ref: "HEAD".to_string(),
reason: e.message().to_string(),
})?
.peel_to_commit()
.map_err(|e| AugentError::GitRefResolveFailed {
git_ref: "HEAD".to_string(),
reason: e.message().to_string(),
})?
}
};
Ok(reference.id().to_string())
}
fn resolve_reference<'a>(repo: &'a Repository, refname: &str) -> Result<git2::Commit<'a>> {
let ref_candidates = [
refname.to_string(),
format!("refs/heads/{}", refname),
format!("refs/tags/{}", refname),
format!("refs/remotes/origin/{}", refname),
];
for candidate in &ref_candidates {
if let Ok(reference) = repo.find_reference(candidate) {
if let Ok(commit) = reference.peel_to_commit() {
return Ok(commit);
}
}
}
if let Ok(oid) = git2::Oid::from_str(refname) {
if let Ok(commit) = repo.find_commit(oid) {
return Ok(commit);
}
}
if let Ok(obj) = repo.revparse_single(refname) {
if let Ok(commit) = obj.peel_to_commit() {
return Ok(commit);
}
}
Err(AugentError::GitRefResolveFailed {
git_ref: refname.to_string(),
reason: "Could not resolve reference".to_string(),
})
}
pub fn checkout_commit(repo: &Repository, sha: &str) -> Result<()> {
let oid = git2::Oid::from_str(sha).map_err(|e| AugentError::GitCheckoutFailed {
sha: sha.to_string(),
reason: e.message().to_string(),
})?;
let commit = repo
.find_commit(oid)
.map_err(|e| AugentError::GitCheckoutFailed {
sha: sha.to_string(),
reason: e.message().to_string(),
})?;
repo.set_head_detached(commit.id())
.map_err(|e| AugentError::GitCheckoutFailed {
sha: sha.to_string(),
reason: e.message().to_string(),
})?;
let mut checkout_builder = git2::build::CheckoutBuilder::new();
checkout_builder.force();
repo.checkout_head(Some(&mut checkout_builder))
.map_err(|e| AugentError::GitCheckoutFailed {
sha: sha.to_string(),
reason: e.message().to_string(),
})?;
Ok(())
}
#[allow(dead_code)] pub fn open(path: &Path) -> Result<Repository> {
Repository::open(path).map_err(|e| AugentError::GitOpenFailed {
path: path.display().to_string(),
reason: e.message().to_string(),
})
}
fn setup_auth_callbacks(callbacks: &mut RemoteCallbacks) {
callbacks.credentials(|url, username_from_url, allowed_types| {
if allowed_types.contains(CredentialType::DEFAULT) {
return Cred::default();
}
if allowed_types.contains(CredentialType::SSH_KEY) {
if let Some(username) = username_from_url {
if let Ok(cred) = Cred::ssh_key_from_agent(username) {
return Ok(cred);
}
let home = dirs::home_dir().unwrap_or_default();
let ssh_dir = home.join(".ssh");
for key_name in &["id_ed25519", "id_rsa", "id_ecdsa"] {
let private_key = ssh_dir.join(key_name);
let public_key = ssh_dir.join(format!("{}.pub", key_name));
if private_key.exists() {
let public_key_path = if public_key.exists() {
Some(public_key.as_path())
} else {
None
};
if let Ok(cred) =
Cred::ssh_key(username, public_key_path, &private_key, None)
{
return Ok(cred);
}
}
}
}
}
if allowed_types.contains(CredentialType::USER_PASS_PLAINTEXT) {
if let Ok(cred) = Cred::credential_helper(
&git2::Config::open_default().unwrap_or_else(|_| git2::Config::new().unwrap()),
url,
username_from_url,
) {
return Ok(cred);
}
if let Ok(cred) = Cred::userpass_plaintext("", "") {
return Ok(cred);
}
if let Some(username) = username_from_url {
if let Ok(cred) = Cred::userpass_plaintext(username, "") {
return Ok(cred);
}
}
for username in &["git", "anonymous"] {
if let Ok(cred) = Cred::userpass_plaintext(username, "") {
return Ok(cred);
}
}
}
Err(git2::Error::new(
git2::ErrorCode::Auth,
git2::ErrorClass::Http,
"authentication failed",
))
});
}
pub fn get_head_ref_name(repo: &Repository) -> Result<Option<String>> {
let head = repo.head().map_err(|e| AugentError::GitRefResolveFailed {
git_ref: "HEAD".to_string(),
reason: e.message().to_string(),
})?;
if head.is_branch() {
if let Some(refname) = head.shorthand() {
Ok(Some(refname.to_string()))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_ls_remote_rejects_file_url() {
let result = ls_remote("file:///tmp/repo", None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("ls-remote not used"));
}
#[test]
fn test_clone_public_repo() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let result = clone(
"https://github.com/octocat/Hello-World.git",
temp.path(),
true,
);
if let Ok(repo) = result {
assert!(repo.head().is_ok());
}
}
#[test]
fn test_resolve_ref_head() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let repo = Repository::init(temp.path()).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let sha = resolve_ref(&repo, None).unwrap();
assert!(!sha.is_empty());
assert_eq!(sha.len(), 40); }
#[test]
fn test_resolve_ref_by_name() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let repo = Repository::init(temp.path()).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let sha = resolve_ref(&repo, Some("master")).or_else(|_| resolve_ref(&repo, Some("main")));
if let Ok(sha) = sha {
assert_eq!(sha, commit_oid.to_string());
}
}
#[test]
fn test_get_head_ref_name() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let repo = Repository::init(temp.path()).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let ref_name = get_head_ref_name(&repo).unwrap();
assert!(ref_name.is_some());
assert!(ref_name == Some("master".to_string()) || ref_name == Some("main".to_string()));
}
#[test]
fn test_get_head_ref_name_detached() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let repo = Repository::init(temp.path()).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let oid = git2::Oid::from_str(&commit_oid.to_string()).unwrap();
let commit = repo.find_commit(oid).unwrap();
repo.set_head_detached(commit.id()).unwrap();
let ref_name = get_head_ref_name(&repo).unwrap();
assert!(ref_name.is_none());
}
#[test]
fn test_checkout_commit() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let repo = Repository::init(temp.path()).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let result = checkout_commit(&repo, &commit_oid.to_string());
assert!(result.is_ok());
}
#[test]
fn test_resolve_ref_invalid() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let repo = Repository::init(temp.path()).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let result = resolve_ref(&repo, Some("nonexistent"));
assert!(result.is_err());
}
#[test]
fn test_checkout_invalid_sha() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let repo = Repository::init(temp.path()).unwrap();
let result = checkout_commit(&repo, "0000000000000000000000000000000000000000");
assert!(result.is_err());
}
#[test]
fn test_open_nonexistent_repo() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let result = open(temp.path().join("nonexistent").as_path());
assert!(result.is_err());
}
#[test]
fn test_resolve_reference_full_sha() {
let temp = TempDir::new_in(crate::temp::temp_dir_base()).unwrap();
let repo = Repository::init(temp.path()).unwrap();
let sig = git2::Signature::now("Test", "test@test.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
index.write_tree().unwrap()
};
let tree = repo.find_tree(tree_id).unwrap();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
let commit = repo.find_commit(commit_oid).unwrap();
let full_sha = commit.id().to_string();
let result = resolve_reference(&repo, &full_sha);
assert!(result.is_ok());
assert_eq!(
result.unwrap().id(),
git2::Oid::from_str(&full_sha).unwrap()
);
}
#[test]
fn test_normalize_ssh_url() {
let scp_url = "git@github.com:user/repo.git";
let normalized = normalize_ssh_url_for_clone(scp_url);
assert_eq!(normalized, "ssh://git@github.com/user/repo.git");
let ssh_url = "ssh://git@github.com/user/repo.git";
let normalized = normalize_ssh_url_for_clone(ssh_url);
assert_eq!(normalized, "ssh://git@github.com/user/repo.git");
let https_url = "https://github.com/user/repo.git";
let normalized = normalize_ssh_url_for_clone(https_url);
assert_eq!(normalized, "https://github.com/user/repo.git");
let scp_url_port = "git@github.com:22:user/repo.git";
let normalized = normalize_ssh_url_for_clone(scp_url_port);
assert!(normalized.starts_with("ssh://git@github.com/"));
let scp_url_no_git = "git@github.com:user/repo";
let normalized = normalize_ssh_url_for_clone(scp_url_no_git);
assert_eq!(normalized, "ssh://git@github.com/user/repo");
let scp_url_absolute = "git@github.com:/absolute/path/repo.git";
let normalized = normalize_ssh_url_for_clone(scp_url_absolute);
assert_eq!(normalized, "ssh://git@github.com/absolute/path/repo.git");
}
}