pub mod command_builder;
#[cfg(test)]
mod tests;
use crate::core::AgpmError;
use crate::git::command_builder::GitCommand;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct GitRepo {
path: PathBuf,
tag_cache: std::sync::Arc<OnceLock<Vec<String>>>,
}
impl GitRepo {
pub fn new(path: impl AsRef<Path>) -> Self {
Self {
path: path.as_ref().to_path_buf(),
tag_cache: std::sync::Arc::new(OnceLock::new()),
}
}
pub async fn clone(url: &str, target: impl AsRef<Path>) -> Result<Self> {
let target_path = target.as_ref();
let mut cmd = GitCommand::clone(url, target_path);
if url.starts_with("file://") {
cmd = GitCommand::clone_local(url, target_path);
}
cmd.execute().await?;
Ok(Self::new(target_path))
}
pub async fn fetch(&self, auth_url: Option<&str>) -> Result<()> {
if let Some(url) = auth_url {
GitCommand::set_remote_url(url).current_dir(&self.path).execute_success().await?;
}
GitCommand::fetch().current_dir(&self.path).execute_success().await?;
Ok(())
}
pub async fn checkout(&self, ref_name: &str) -> Result<()> {
let reset_result = GitCommand::reset_hard().current_dir(&self.path).execute().await;
if let Err(e) = reset_result {
let error_str = e.to_string();
if !error_str.contains("HEAD detached") {
eprintln!("Warning: git reset failed: {error_str}");
}
}
let remote_ref = format!("origin/{ref_name}");
let check_remote =
GitCommand::verify_ref(&remote_ref).current_dir(&self.path).execute().await;
if check_remote.is_ok() {
if GitCommand::checkout_branch(ref_name, &remote_ref)
.current_dir(&self.path)
.execute_success()
.await
.is_ok()
{
return Ok(());
}
}
GitCommand::checkout(ref_name).current_dir(&self.path).execute_success().await.map_err(
|e| {
if let Some(agpm_err) = e.downcast_ref::<AgpmError>()
&& matches!(agpm_err, AgpmError::GitCheckoutFailed { .. })
{
return e;
}
AgpmError::GitCheckoutFailed {
reference: ref_name.to_string(),
reason: e.to_string(),
}
.into()
},
)
}
pub async fn list_tags(&self) -> Result<Vec<String>> {
if let Some(cached_tags) = self.tag_cache.get() {
return Ok(cached_tags.clone());
}
if !self.path.exists() {
return Err(anyhow::anyhow!("Repository path does not exist: {:?}", self.path));
}
if !self.path.join(".git").exists() && !self.path.join("HEAD").exists() {
return Err(anyhow::anyhow!("Not a git repository: {:?}", self.path));
}
const MAX_RETRIES: u32 = 3;
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(150);
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
let result = GitCommand::list_tags().current_dir(&self.path).execute_stdout().await;
match result {
Ok(stdout) => {
let tags: Vec<String> = stdout
.lines()
.filter(|line| !line.is_empty())
.map(std::string::ToString::to_string)
.collect();
let _ = self.tag_cache.set(tags.clone());
return Ok(tags);
}
Err(e) => {
let error_str = e.to_string();
if error_str.contains("lock") {
last_error = Some(e);
tokio::time::sleep(RETRY_DELAY * (attempt + 1)).await; continue;
}
return Err(e).context(format!("Failed to list git tags in {:?}", self.path));
}
}
}
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Exhausted retries for list_tags")))
.context(format!(
"Failed to list git tags in {:?} after {} retries",
self.path, MAX_RETRIES
))
}
pub async fn get_remote_url(&self) -> Result<String> {
GitCommand::remote_url().current_dir(&self.path).execute_stdout().await
}
#[must_use]
pub fn is_git_repo(&self) -> bool {
is_git_repository(&self.path)
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
pub async fn verify_url(url: &str) -> Result<()> {
if url.starts_with("file://") {
let path = url.strip_prefix("file://").unwrap();
return if std::path::Path::new(path).exists() {
Ok(())
} else {
Err(anyhow::anyhow!("Local path does not exist: {path}"))
};
}
GitCommand::ls_remote(url)
.execute_success()
.await
.context("Failed to verify remote repository")
}
async fn ensure_bare_repo_has_refs_with_context(&self, context: Option<&str>) -> Result<()> {
let mut fetch_cmd = GitCommand::fetch().current_dir(&self.path);
if let Some(ctx) = context {
fetch_cmd = fetch_cmd.with_context(ctx);
}
let fetch_result = fetch_cmd.execute_success().await;
if fetch_result.is_err() {
let mut check_cmd =
GitCommand::new().args(["show-ref", "--head"]).current_dir(&self.path);
if let Some(ctx) = context {
check_cmd = check_cmd.with_context(ctx);
}
check_cmd
.execute_success()
.await
.map_err(|e| anyhow::anyhow!("Bare repository has no refs available: {e}"))?;
}
Ok(())
}
pub async fn clone_bare(url: &str, target: impl AsRef<Path>) -> Result<Self> {
Self::clone_bare_with_context(url, target, None).await
}
pub async fn clone_bare_with_context(
url: &str,
target: impl AsRef<Path>,
context: Option<&str>,
) -> Result<Self> {
let target_path = target.as_ref();
let mut cmd = GitCommand::clone_bare(url, target_path);
if let Some(ctx) = context {
cmd = cmd.with_context(ctx);
}
cmd.execute_success().await?;
let repo = Self::new(target_path);
let _ = GitCommand::new()
.args(["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"])
.current_dir(repo.path())
.execute_success()
.await;
repo.ensure_bare_repo_has_refs_with_context(context).await.ok();
Ok(repo)
}
pub async fn create_worktree(
&self,
worktree_path: impl AsRef<Path>,
reference: Option<&str>,
) -> Result<Self> {
self.create_worktree_with_context(worktree_path, reference, None).await
}
pub async fn create_worktree_with_context(
&self,
worktree_path: impl AsRef<Path>,
reference: Option<&str>,
context: Option<&str>,
) -> Result<Self> {
let worktree_path = worktree_path.as_ref();
if let Some(parent) = worktree_path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!("Failed to create parent directory for worktree: {parent:?}")
})?;
}
let max_retries = 3;
let mut retry_count = 0;
loop {
let default_branch = if reference.is_none() && retry_count == 0 {
GitCommand::new()
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.current_dir(&self.path)
.execute_stdout()
.await
.ok()
.and_then(|s| s.strip_prefix("refs/remotes/origin/").map(String::from))
.or_else(|| Some("main".to_string()))
} else {
None
};
let effective_ref = if let Some(ref branch) = default_branch {
Some(branch.as_str())
} else {
reference
};
let mut cmd =
GitCommand::worktree_add(worktree_path, effective_ref).current_dir(&self.path);
if let Some(ctx) = context {
cmd = cmd.with_context(ctx);
}
let result = cmd.execute_success().await;
match result {
Ok(()) => {
let worktree_repo = Self::new(worktree_path);
let mut init_cmd =
GitCommand::new().args(["submodule", "init"]).current_dir(worktree_path);
if let Some(ctx) = context {
init_cmd = init_cmd.with_context(ctx);
}
if let Err(e) = init_cmd.execute_success().await {
let error_str = e.to_string();
if !error_str.contains("No submodule mapping found")
&& !error_str.contains("no submodule")
{
return Err(e).context("Failed to initialize submodules");
}
}
let mut update_cmd = GitCommand::new()
.args(["submodule", "update", "--recursive"])
.current_dir(worktree_path);
if let Some(ctx) = context {
update_cmd = update_cmd.with_context(ctx);
}
if let Err(e) = update_cmd.execute_success().await {
let error_str = e.to_string();
if !error_str.contains("No submodule mapping found")
&& !error_str.contains("no submodule")
{
return Err(e).context("Failed to update submodules");
}
}
return Ok(worktree_repo);
}
Err(e) => {
let error_str = e.to_string();
if error_str.contains("already exists")
|| error_str.contains("is already checked out")
|| error_str.contains("fatal: could not create directory")
|| (error_str.contains("failed to read") && error_str.contains("commondir"))
{
retry_count += 1;
if retry_count >= max_retries {
return Err(e).with_context(|| {
format!(
"Failed to create worktree at {} from {} after {} retries",
worktree_path.display(),
self.path.display(),
max_retries
)
});
}
tokio::time::sleep(tokio::time::Duration::from_millis(100 * retry_count))
.await;
continue;
}
if error_str.contains("missing but already registered worktree") {
if worktree_path.exists() {
let _ = tokio::fs::remove_dir_all(worktree_path).await;
}
let mut prune_cmd =
GitCommand::new().args(["worktree", "prune"]).current_dir(&self.path);
if let Some(ctx) = context {
prune_cmd = prune_cmd.with_context(ctx);
}
let _ = prune_cmd.execute_success().await;
if let Some(parent) = worktree_path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let worktree_path_str = worktree_path.display().to_string();
let mut args = vec![
"worktree".to_string(),
"add".to_string(),
"--force".to_string(),
worktree_path_str,
];
if let Some(r) = effective_ref {
args.push(r.to_string());
}
let mut force_cmd = GitCommand::new().args(args).current_dir(&self.path);
if let Some(ctx) = context {
force_cmd = force_cmd.with_context(ctx);
}
match force_cmd.execute_success().await {
Ok(()) => {
let worktree_repo = Self::new(worktree_path);
let mut init_cmd = GitCommand::new()
.args(["submodule", "init"])
.current_dir(worktree_path);
if let Some(ctx) = context {
init_cmd = init_cmd.with_context(ctx);
}
let _ = init_cmd.execute_success().await;
let mut update_cmd = GitCommand::new()
.args(["submodule", "update", "--recursive"])
.current_dir(worktree_path);
if let Some(ctx) = context {
update_cmd = update_cmd.with_context(ctx);
}
let _ = update_cmd.execute_success().await;
return Ok(worktree_repo);
}
Err(e2) => {
return Err(e).with_context(|| {
format!(
"Failed to create worktree at {} from {} (forced add failed: {})",
worktree_path.display(),
self.path.display(),
e2
)
});
}
}
}
if reference.is_none() && retry_count == 0 {
let mut head_cmd = GitCommand::worktree_add(worktree_path, Some("HEAD"))
.current_dir(&self.path);
if let Some(ctx) = context {
head_cmd = head_cmd.with_context(ctx);
}
let head_result = head_cmd.execute_success().await;
match head_result {
Ok(()) => {
let worktree_repo = Self::new(worktree_path);
let mut init_cmd = GitCommand::new()
.args(["submodule", "init"])
.current_dir(worktree_path);
if let Some(ctx) = context {
init_cmd = init_cmd.with_context(ctx);
}
if let Err(e) = init_cmd.execute_success().await {
let error_str = e.to_string();
if !error_str.contains("No submodule mapping found")
&& !error_str.contains("no submodule")
{
return Err(e).context("Failed to initialize submodules");
}
}
let mut update_cmd = GitCommand::new()
.args(["submodule", "update", "--recursive"])
.current_dir(worktree_path);
if let Some(ctx) = context {
update_cmd = update_cmd.with_context(ctx);
}
if let Err(e) = update_cmd.execute_success().await {
let error_str = e.to_string();
if !error_str.contains("No submodule mapping found")
&& !error_str.contains("no submodule")
{
return Err(e).context("Failed to update submodules");
}
}
return Ok(worktree_repo);
}
Err(head_err) => {
return Err(e).with_context(|| {
format!(
"Failed to create worktree at {} from {} (also tried HEAD: {})",
worktree_path.display(),
self.path.display(),
head_err
)
});
}
}
}
let error_str = e.to_string();
if let Some(ref_name) = reference
&& (error_str.contains("pathspec")
|| error_str.contains("not found")
|| error_str.contains("ambiguous")
|| error_str.contains("invalid")
|| error_str.contains("unknown revision"))
{
return Err(anyhow::anyhow!(
"Invalid version or reference '{ref_name}': Failed to checkout reference - the specified version/tag/branch does not exist in the repository"
));
}
return Err(e).with_context(|| {
format!(
"Failed to create worktree at {} from {}",
worktree_path.display(),
self.path.display()
)
});
}
}
}
}
pub async fn remove_worktree(&self, worktree_path: impl AsRef<Path>) -> Result<()> {
let worktree_path = worktree_path.as_ref();
GitCommand::worktree_remove(worktree_path)
.current_dir(&self.path)
.execute_success()
.await
.with_context(|| format!("Failed to remove worktree at {}", worktree_path.display()))?;
if worktree_path.exists() {
tokio::fs::remove_dir_all(worktree_path).await.ok(); }
Ok(())
}
pub async fn list_worktrees(&self) -> Result<Vec<PathBuf>> {
let output = GitCommand::worktree_list().current_dir(&self.path).execute_stdout().await?;
let mut worktrees = Vec::new();
let mut current_worktree: Option<PathBuf> = None;
for line in output.lines() {
if line.starts_with("worktree ") {
if let Some(path) = line.strip_prefix("worktree ") {
current_worktree = Some(PathBuf::from(path));
}
} else if line == "bare" {
current_worktree = None;
} else if line.is_empty()
&& current_worktree.is_some()
&& let Some(path) = current_worktree.take()
{
worktrees.push(path);
}
}
if let Some(path) = current_worktree {
worktrees.push(path);
}
Ok(worktrees)
}
pub async fn prune_worktrees(&self) -> Result<()> {
GitCommand::worktree_prune()
.current_dir(&self.path)
.execute_success()
.await
.with_context(|| "Failed to prune worktrees")?;
Ok(())
}
pub async fn is_bare(&self) -> Result<bool> {
let output = GitCommand::new()
.args(["config", "--get", "core.bare"])
.current_dir(&self.path)
.execute_stdout()
.await?;
Ok(output.trim() == "true")
}
pub async fn get_current_commit(&self) -> Result<String> {
GitCommand::current_commit()
.current_dir(&self.path)
.execute_stdout()
.await
.context("Failed to get current commit")
}
pub async fn resolve_refs_batch(
&self,
refs: &[&str],
) -> Result<std::collections::HashMap<String, Option<String>>> {
use std::collections::HashMap;
if refs.is_empty() {
return Ok(HashMap::new());
}
let (already_shas, to_resolve): (Vec<&str>, Vec<&str>) =
refs.iter().partition(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()));
let mut results: HashMap<String, Option<String>> = HashMap::new();
for sha in already_shas {
results.insert(sha.to_string(), Some(sha.to_string()));
}
if to_resolve.is_empty() {
return Ok(results);
}
let mut args = vec!["rev-parse"];
args.extend(to_resolve.iter().copied());
let output = GitCommand::new().args(args).current_dir(&self.path).execute().await;
match output {
Ok(cmd_output) => {
let shas: Vec<&str> = cmd_output.stdout.lines().collect();
for (i, ref_name) in to_resolve.iter().enumerate() {
let sha = shas.get(i).and_then(|s| {
let trimmed = s.trim();
if trimmed.len() == 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
Some(trimmed.to_string())
} else {
None
}
});
results.insert(ref_name.to_string(), sha);
}
}
Err(e) => {
tracing::debug!(
target: "git",
"Batch rev-parse failed, falling back to individual resolution: {}",
e
);
for ref_name in to_resolve {
let sha = GitCommand::rev_parse(ref_name)
.current_dir(&self.path)
.execute_stdout()
.await
.ok();
results.insert(ref_name.to_string(), sha);
}
}
}
Ok(results)
}
pub async fn resolve_to_sha(&self, ref_spec: Option<&str>) -> Result<String> {
let reference = ref_spec.unwrap_or("HEAD");
if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(reference.to_string());
}
let ref_to_resolve = if !reference.contains('/') && reference != "HEAD" {
let is_tag = self
.list_tags()
.await
.map(|tags| tags.contains(&reference.to_string()))
.unwrap_or(false);
if is_tag {
reference.to_string()
} else {
let origin_ref = format!("origin/{reference}");
if GitCommand::rev_parse(&origin_ref)
.current_dir(&self.path)
.execute_stdout()
.await
.is_ok()
{
origin_ref
} else {
reference.to_string()
}
}
} else {
reference.to_string()
};
let sha = GitCommand::rev_parse(&ref_to_resolve)
.current_dir(&self.path)
.execute_stdout()
.await
.with_context(|| format!("Failed to resolve reference '{reference}' to SHA"))?;
if sha.len() < 40 {
let full_sha = GitCommand::new()
.args(["rev-parse", "--verify", &format!("{reference}^{{commit}}")])
.current_dir(&self.path)
.execute_stdout()
.await
.with_context(|| format!("Failed to get full SHA for reference '{reference}'"))?;
Ok(full_sha)
} else {
Ok(sha)
}
}
pub async fn get_current_branch(&self) -> Result<String> {
let branch = GitCommand::current_branch()
.current_dir(&self.path)
.execute_stdout()
.await
.context("Failed to get current branch")?;
if branch.is_empty() {
Ok("master".to_string())
} else {
Ok(branch)
}
}
pub async fn get_default_branch(&self) -> Result<String> {
let result = GitCommand::new()
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.current_dir(&self.path)
.execute_stdout()
.await;
match result {
Ok(symbolic_ref) => {
if let Some(branch) = symbolic_ref.strip_prefix("refs/remotes/origin/") {
return Ok(branch.to_string());
}
}
Err(e) => {
let error_str = e.to_string();
if !error_str.contains("not a symbolic ref") && !error_str.contains("not found") {
return Err(e).context("Failed to get default branch via symbolic-ref");
}
}
}
self.get_current_branch().await
}
}
#[must_use]
pub fn is_git_installed() -> bool {
std::process::Command::new(crate::utils::platform::get_git_command())
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn ensure_git_available() -> Result<()> {
if !is_git_installed() {
return Err(AgpmError::GitNotFound.into());
}
Ok(())
}
#[must_use]
pub fn is_git_repository(path: &Path) -> bool {
path.join(".git").exists() || path.join("HEAD").exists()
}
#[must_use]
pub fn is_valid_git_repo(path: &Path) -> bool {
is_git_repository(path)
}
pub fn ensure_valid_git_repo(path: &Path) -> Result<()> {
if !is_valid_git_repo(path) {
return Err(AgpmError::GitRepoInvalid {
path: path.display().to_string(),
}
.into());
}
Ok(())
}
pub fn parse_git_url(url: &str) -> Result<(String, String)> {
use std::path::Path;
if url.starts_with("file://") {
let path_str = url.trim_start_matches("file://");
let path = Path::new(path_str);
let repo_name = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.trim_end_matches(".git"))
.unwrap_or(path_str);
return Ok(("local".to_string(), repo_name.to_string()));
}
if url.starts_with('/') || url.starts_with("./") || url.starts_with("../") {
let path = Path::new(url);
let repo_name = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.trim_end_matches(".git"))
.unwrap_or(url);
return Ok(("local".to_string(), repo_name.to_string()));
}
if url.contains('@')
&& url.contains(':')
&& !url.starts_with("ssh://")
&& let Some(colon_pos) = url.find(':')
{
let path = &url[colon_pos + 1..];
let path = path.trim_end_matches(".git");
if let Some(slash_pos) = path.find('/') {
return Ok((path[..slash_pos].to_string(), path[slash_pos + 1..].to_string()));
}
}
if url.contains("github.com") || url.contains("gitlab.com") || url.contains("bitbucket.org") {
let parts: Vec<&str> = url.split('/').collect();
if parts.len() >= 2 {
let repo = parts[parts.len() - 1].trim_end_matches(".git");
let owner = parts[parts.len() - 2];
return Ok((owner.to_string(), repo.to_string()));
}
}
Err(anyhow::anyhow!("Could not parse repository owner and name from URL"))
}
pub fn strip_auth_from_url(url: &str) -> Result<String> {
if url.starts_with("https://") || url.starts_with("http://") {
if let Some(at_pos) = url.find('@') {
let protocol_end = if url.starts_with("https://") {
"https://".len()
} else {
"http://".len()
};
let first_slash = url[protocol_end..].find('/').map(|p| p + protocol_end);
if first_slash.is_none() || at_pos < first_slash.unwrap() {
let protocol = &url[..protocol_end];
let after_auth = &url[at_pos + 1..];
return Ok(format!("{protocol}{after_auth}"));
}
}
}
Ok(url.to_string())
}