Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 4m3s
CI / Security Audit (push) Successful in 1m38s
CI / Tests (push) Successful in 4m44s
CI / Detect Changes (push) Successful in 2s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Failing after 2s
240 lines
7.8 KiB
Rust
240 lines
7.8 KiB
Rust
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<String>,
|
|
/// Auth token / password (for HTTPS URLs)
|
|
pub auth_token: Option<String>,
|
|
/// Username for HTTPS auth (defaults to "x-access-token")
|
|
pub auth_username: Option<String>,
|
|
}
|
|
|
|
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<PathBuf, AgentError> {
|
|
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<String, AgentError> {
|
|
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<bool, AgentError> {
|
|
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<Vec<DiffFile>, 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<DiffFile> = 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<Vec<String>, 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,
|
|
}
|