use std::path::{Path, PathBuf}; use git2::{Cred, FetchOptions, RemoteCallbacks, Repository}; use crate::error::AgentError; /// Credentials for accessing a private repository #[derive(Debug, Clone, Default)] pub struct RepoCredentials { /// Path to the SSH private key (for SSH URLs) pub ssh_key_path: Option, /// Auth token / password (for HTTPS URLs) pub auth_token: Option, /// Username for HTTPS auth (defaults to "x-access-token") pub auth_username: Option, } impl RepoCredentials { pub(crate) fn make_callbacks(&self) -> RemoteCallbacks<'_> { let mut callbacks = RemoteCallbacks::new(); let ssh_key = self.ssh_key_path.clone(); let token = self.auth_token.clone(); let username = self.auth_username.clone(); callbacks.credentials(move |_url, username_from_url, allowed_types| { // SSH key authentication if allowed_types.contains(git2::CredentialType::SSH_KEY) { if let Some(ref key_path) = ssh_key { let key = Path::new(key_path); if key.exists() { let user = username_from_url.unwrap_or("git"); return Cred::ssh_key(user, None, key, None); } } } // HTTPS userpass authentication if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { if let Some(ref tok) = token { let user = username.as_deref().unwrap_or("x-access-token"); return Cred::userpass_plaintext(user, tok); } } Cred::default() }); callbacks } fn fetch_options(&self) -> FetchOptions<'_> { let mut fetch_opts = FetchOptions::new(); if self.has_credentials() { fetch_opts.remote_callbacks(self.make_callbacks()); } fetch_opts } fn has_credentials(&self) -> bool { self.ssh_key_path .as_ref() .map(|p| Path::new(p).exists()) .unwrap_or(false) || self.auth_token.is_some() } } pub struct GitOps { base_path: PathBuf, credentials: RepoCredentials, } impl GitOps { pub fn new(base_path: &str, credentials: RepoCredentials) -> Self { Self { base_path: PathBuf::from(base_path), credentials, } } pub fn clone_or_fetch(&self, git_url: &str, repo_name: &str) -> Result { let repo_path = self.base_path.join(repo_name); if repo_path.exists() { self.fetch(&repo_path)?; } else { std::fs::create_dir_all(&repo_path)?; self.clone_repo(git_url, &repo_path)?; tracing::info!("Cloned {git_url} to {}", repo_path.display()); } Ok(repo_path) } fn clone_repo(&self, git_url: &str, repo_path: &Path) -> Result<(), AgentError> { let mut builder = git2::build::RepoBuilder::new(); let fetch_opts = self.credentials.fetch_options(); builder.fetch_options(fetch_opts); builder.clone(git_url, repo_path)?; Ok(()) } fn fetch(&self, repo_path: &Path) -> Result<(), AgentError> { let repo = Repository::open(repo_path)?; let mut remote = repo.find_remote("origin")?; let mut fetch_opts = self.credentials.fetch_options(); remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?; // Fast-forward to origin/HEAD let fetch_head = repo.find_reference("FETCH_HEAD")?; let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?; let head_ref = repo.head()?; let head_name = head_ref.name().unwrap_or("HEAD"); repo.reference(head_name, fetch_commit.id(), true, "fast-forward")?; repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?; tracing::info!("Fetched and fast-forwarded {}", repo_path.display()); Ok(()) } /// Test that we can access a remote repository (used during add validation) pub fn test_access(git_url: &str, credentials: &RepoCredentials) -> Result<(), AgentError> { let mut remote = git2::Remote::create_detached(git_url)?; let callbacks = credentials.make_callbacks(); remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)?; remote.disconnect()?; Ok(()) } pub fn get_head_sha(repo_path: &Path) -> Result { let repo = Repository::open(repo_path)?; let head = repo.head()?; let commit = head.peel_to_commit()?; Ok(commit.id().to_string()) } pub fn has_new_commits(repo_path: &Path, last_sha: Option<&str>) -> Result { let current_sha = Self::get_head_sha(repo_path)?; match last_sha { Some(sha) if sha == current_sha => Ok(false), _ => Ok(true), } } /// Extract structured diff content between two commits pub fn get_diff_content( repo_path: &Path, old_sha: &str, new_sha: &str, ) -> Result, AgentError> { let repo = Repository::open(repo_path)?; let old_commit = repo.find_commit(git2::Oid::from_str(old_sha)?)?; let new_commit = repo.find_commit(git2::Oid::from_str(new_sha)?)?; let old_tree = old_commit.tree()?; let new_tree = new_commit.tree()?; let diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?; let mut diff_files: Vec = Vec::new(); diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| { let file_path = delta .new_file() .path() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); // Find or create the DiffFile entry let idx = if let Some(pos) = diff_files.iter().position(|f| f.path == file_path) { pos } else { diff_files.push(DiffFile { path: file_path, hunks: String::new(), }); diff_files.len() - 1 }; let diff_file = &mut diff_files[idx]; let prefix = match line.origin() { '+' => "+", '-' => "-", ' ' => " ", _ => "", }; let content = std::str::from_utf8(line.content()).unwrap_or(""); diff_file.hunks.push_str(prefix); diff_file.hunks.push_str(content); true })?; // Filter out binary files and very large diffs diff_files.retain(|f| !f.hunks.is_empty() && f.hunks.len() < 50_000); Ok(diff_files) } #[allow(dead_code)] pub fn get_changed_files( repo_path: &Path, old_sha: &str, new_sha: &str, ) -> Result, AgentError> { let repo = Repository::open(repo_path)?; let old_commit = repo.find_commit(git2::Oid::from_str(old_sha)?)?; let new_commit = repo.find_commit(git2::Oid::from_str(new_sha)?)?; let old_tree = old_commit.tree()?; let new_tree = new_commit.tree()?; let diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?; let mut files = Vec::new(); diff.foreach( &mut |delta, _| { if let Some(path) = delta.new_file().path() { files.push(path.to_string_lossy().to_string()); } true }, None, None, None, )?; Ok(files) } } /// A file changed between two commits with its diff content #[derive(Debug, Clone)] pub struct DiffFile { pub path: String, pub hunks: String, }