use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use sha2::{Digest, Sha256};
use crate::canonicalize::canonicalize;
use crate::client::GitClawClient;
use crate::error::Error;
use crate::types::{GitRef, PushResult, RefUpdate, RefUpdateStatus};
pub struct GitHelper<'a> {
client: &'a GitClawClient,
}
impl<'a> GitHelper<'a> {
#[must_use]
pub fn new(client: &'a GitClawClient) -> Self {
Self { client }
}
pub fn clone(
&self,
clone_url: &str,
local_path: &Path,
depth: Option<u32>,
branch: Option<&str>,
) -> Result<(), Error> {
let mut cmd = Command::new("git");
cmd.arg("clone");
if let Some(d) = depth {
cmd.args(["--depth", &d.to_string()]);
}
if let Some(b) = branch {
cmd.args(["--branch", b]);
}
let auth_url = self.build_authenticated_url(clone_url);
cmd.arg(&auth_url);
cmd.arg(local_path);
let output = cmd.output().map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Http(format!("Git clone failed: {stderr}")));
}
Ok(())
}
pub fn push(
&self,
local_path: &Path,
remote: Option<&str>,
branch: Option<&str>,
force: bool,
) -> Result<PushResult, Error> {
let remote = remote.unwrap_or("origin");
let branch = branch.unwrap_or("main");
let head_oid = self.get_head_oid(local_path)?;
let remote_oid = self.get_remote_ref(local_path, remote, branch)?;
let packfile = self.build_packfile(local_path, remote_oid.as_deref(), &head_oid)?;
let packfile_hash = self.compute_packfile_hash(&packfile);
let ref_updates = vec![RefUpdate {
ref_name: format!("refs/heads/{branch}"),
old_oid: remote_oid.unwrap_or_else(|| "0".repeat(40)),
new_oid: head_oid,
force,
}];
let ref_updates_value = serde_json::to_value(&ref_updates)
.map_err(|e| Error::Serialization(e))?;
let _canonical_ref_updates = canonicalize(&ref_updates_value)?;
let mut body: HashMap<String, serde_json::Value> = HashMap::new();
body.insert(
"packfileHash".to_string(),
serde_json::Value::String(packfile_hash),
);
body.insert("refUpdates".to_string(), ref_updates_value);
let envelope = self.client.transport().envelope_builder().build("git_push", body);
let _signature = sign_envelope(&envelope, self.client.transport().agent_id())?;
let mut cmd = Command::new("git");
cmd.current_dir(local_path);
cmd.arg("push");
if force {
cmd.arg("--force");
}
cmd.args([remote, branch]);
cmd.env("GITCLAW_AGENT_ID", self.client.agent_id());
cmd.env("GITCLAW_NONCE", &envelope.nonce);
let output = cmd.output().map_err(|e| Error::Io(e))?;
if output.status.success() {
Ok(PushResult {
status: "ok".to_string(),
ref_updates: vec![RefUpdateStatus {
ref_name: format!("refs/heads/{branch}"),
status: "ok".to_string(),
message: None,
}],
})
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(PushResult {
status: "error".to_string(),
ref_updates: vec![RefUpdateStatus {
ref_name: format!("refs/heads/{branch}"),
status: "error".to_string(),
message: Some(stderr.to_string()),
}],
})
}
}
pub fn fetch(&self, local_path: &Path, remote: Option<&str>, prune: bool) -> Result<(), Error> {
let remote = remote.unwrap_or("origin");
let mut cmd = Command::new("git");
cmd.current_dir(local_path);
cmd.args(["fetch", remote]);
if prune {
cmd.arg("--prune");
}
let output = cmd.output().map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Http(format!("Git fetch failed: {stderr}")));
}
Ok(())
}
pub fn get_refs(&self, local_path: &Path) -> Result<Vec<GitRef>, Error> {
let output = Command::new("git")
.current_dir(local_path)
.arg("show-ref")
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
return Ok(Vec::new());
}
let head_output = Command::new("git")
.current_dir(local_path)
.args(["symbolic-ref", "HEAD"])
.output()
.map_err(|e| Error::Io(e))?;
let head_ref = if head_output.status.success() {
Some(String::from_utf8_lossy(&head_output.stdout).trim().to_string())
} else {
None
};
let stdout = String::from_utf8_lossy(&output.stdout);
let refs: Vec<GitRef> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(2, ' ').collect();
if parts.len() == 2 {
Some(GitRef {
oid: parts[0].to_string(),
name: parts[1].to_string(),
is_head: head_ref.as_ref().map_or(false, |h| h == parts[1]),
})
} else {
None
}
})
.collect();
Ok(refs)
}
fn build_authenticated_url(&self, clone_url: &str) -> String {
clone_url.to_string()
}
fn get_head_oid(&self, local_path: &Path) -> Result<String, Error> {
let output = Command::new("git")
.current_dir(local_path)
.args(["rev-parse", "HEAD"])
.output()
.map_err(|e| Error::Io(e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Http(format!("Failed to get HEAD: {stderr}")));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn get_remote_ref(
&self,
local_path: &Path,
remote: &str,
branch: &str,
) -> Result<Option<String>, Error> {
let output = Command::new("git")
.current_dir(local_path)
.args(["rev-parse", &format!("{remote}/{branch}")])
.output()
.map_err(|e| Error::Io(e))?;
if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
Ok(None)
}
}
fn build_packfile(
&self,
local_path: &Path,
old_oid: Option<&str>,
new_oid: &str,
) -> Result<Vec<u8>, Error> {
let rev_range = if let Some(old) = old_oid {
format!("{old}..{new_oid}")
} else {
new_oid.to_string()
};
let rev_list = Command::new("git")
.current_dir(local_path)
.args(["rev-list", "--objects", &rev_range])
.output()
.map_err(|e| Error::Io(e))?;
if !rev_list.status.success() {
return Ok(Vec::new());
}
let mut pack_objects = Command::new("git")
.current_dir(local_path)
.args(["pack-objects", "--stdout"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.map_err(|e| Error::Io(e))?;
use std::io::Write;
if let Some(ref mut stdin) = pack_objects.stdin {
stdin.write_all(&rev_list.stdout).ok();
}
let output = pack_objects
.wait_with_output()
.map_err(|e| Error::Io(e))?;
Ok(output.stdout)
}
fn compute_packfile_hash(&self, packfile: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(packfile);
hex::encode(hasher.finalize())
}
}
fn sign_envelope(
envelope: &crate::envelope::SignatureEnvelope,
_agent_id: &str,
) -> Result<String, Error> {
Ok(format!("signed:{}", envelope.nonce))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signers::Ed25519Signer;
use std::sync::Arc;
fn create_test_client() -> GitClawClient {
let (signer, _) = Ed25519Signer::generate();
GitClawClient::new("test-agent", Arc::new(signer), None, None, None)
.expect("Client creation should succeed")
}
#[test]
fn test_git_helper_creation() {
let client = create_test_client();
let _helper = GitHelper::new(&client);
}
#[test]
fn test_compute_packfile_hash() {
let client = create_test_client();
let helper = GitHelper::new(&client);
let packfile = b"test packfile content";
let hash = helper.compute_packfile_hash(packfile);
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_packfile_hash_is_deterministic() {
let client = create_test_client();
let helper = GitHelper::new(&client);
let packfile = b"test packfile content";
let hash1 = helper.compute_packfile_hash(packfile);
let hash2 = helper.compute_packfile_hash(packfile);
assert_eq!(hash1, hash2);
}
}