use serde_json::Value;
use crate::error::Result;
use crate::transport::DataPlaneClient;
#[derive(Clone, Debug, Default)]
pub struct GitCommandResult {
pub path: Option<String>,
pub url: Option<String>,
pub ref_name: Option<String>,
pub branch: Option<String>,
pub remote: Option<String>,
pub name: Option<String>,
pub value: Option<String>,
pub branches: Vec<String>,
pub current_branch: Option<String>,
pub stdout: String,
pub stderr: String,
pub command: Option<Value>,
pub raw: Value,
}
#[derive(Clone, Debug, Default)]
pub struct GitFileStatus {
pub name: String,
pub status: String,
pub index_status: String,
pub working_tree_status: String,
pub staged: bool,
pub renamed_from: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct GitStatus {
pub current_branch: Option<String>,
pub upstream: Option<String>,
pub ahead: u64,
pub behind: u64,
pub detached: bool,
pub file_status: Vec<GitFileStatus>,
pub result: GitCommandResult,
}
#[derive(Clone, Debug, Default)]
pub struct GitBranches {
pub path: Option<String>,
pub branches: Vec<String>,
pub current_branch: Option<String>,
pub result: GitCommandResult,
}
impl GitStatus {
pub fn is_clean(&self) -> bool {
self.file_status.is_empty()
}
pub fn has_changes(&self) -> bool {
!self.file_status.is_empty()
}
pub fn has_staged(&self) -> bool {
self.file_status.iter().any(|item| item.staged)
}
pub fn has_untracked(&self) -> bool {
self.file_status
.iter()
.any(|item| item.status == "untracked")
}
pub fn total_count(&self) -> usize {
self.file_status.len()
}
}
#[derive(Clone, Debug, Default)]
pub struct GitCloneOptions {
pub path: Option<String>,
pub branch: Option<String>,
pub depth: Option<u64>,
pub recursive: bool,
pub submodules: bool,
pub username: Option<String>,
pub password: Option<String>,
pub dangerously_store_credentials: bool,
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct GitRequestOptions {
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct GitCredentialOptions {
pub username: String,
pub password: String,
pub host: Option<String>,
pub protocol: Option<String>,
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct GitConfigureUserOptions {
pub scope: Option<String>,
pub path: Option<String>,
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct GitDeleteBranchOptions {
pub force: bool,
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct GitAddOptions {
pub files: Vec<String>,
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct GitCommitOptions {
pub author_name: Option<String>,
pub author_email: Option<String>,
pub allow_empty: bool,
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct GitRemoteOperationOptions {
pub remote: Option<String>,
pub branch: Option<String>,
pub set_upstream: bool,
pub username: Option<String>,
pub password: Option<String>,
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct GitRemoteAddOptions {
pub fetch: bool,
pub overwrite: bool,
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default)]
pub struct GitConfigOptions {
pub scope: Option<String>,
pub path: Option<String>,
pub envs: serde_json::Map<String, Value>,
pub timeout_seconds: Option<u64>,
}
#[derive(Clone)]
pub struct Git {
data_plane: DataPlaneClient,
}
impl Git {
pub(crate) fn new(data_plane: DataPlaneClient) -> Self {
Self { data_plane }
}
pub async fn clone(&self, url: &str, opts: GitCloneOptions) -> Result<GitCommandResult> {
let mut body = serde_json::Map::new();
body.insert("url".into(), Value::String(url.to_string()));
put_if_some_string(&mut body, "path", opts.path);
put_if_some_string(&mut body, "branch", opts.branch);
put_if_some_u64(&mut body, "depth", opts.depth);
if opts.recursive {
body.insert("recursive".into(), Value::Bool(true));
}
if opts.submodules {
body.insert("submodules".into(), Value::Bool(true));
}
put_if_some_string(&mut body, "username", opts.username);
put_if_some_string(&mut body, "password", opts.password);
if opts.dangerously_store_credentials {
body.insert("dangerously_store_credentials".into(), Value::Bool(true));
}
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
self.run("/runtime/v1/git/clone", Value::Object(body)).await
}
pub async fn dangerously_authenticate(
&self,
opts: GitCredentialOptions,
) -> Result<GitCommandResult> {
let mut body = serde_json::Map::new();
body.insert("username".into(), Value::String(opts.username));
body.insert("password".into(), Value::String(opts.password));
put_if_some_string(&mut body, "host", opts.host);
put_if_some_string(&mut body, "protocol", opts.protocol);
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
self.run(
"/runtime/v1/git/dangerously_authenticate",
Value::Object(body),
)
.await
}
pub async fn configure_user(
&self,
name: &str,
email: &str,
opts: GitConfigureUserOptions,
) -> Result<GitCommandResult> {
let mut body = serde_json::Map::new();
body.insert("name".into(), Value::String(name.to_string()));
body.insert("email".into(), Value::String(email.to_string()));
put_if_some_string(&mut body, "scope", opts.scope);
put_if_some_string(&mut body, "path", opts.path);
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
self.run("/runtime/v1/git/configure_user", Value::Object(body))
.await
}
pub async fn status(&self, path: &str, opts: GitRequestOptions) -> Result<GitStatus> {
let result = self
.run("/runtime/v1/git/status", repo_body(path, opts))
.await?;
Ok(parse_status(result))
}
pub async fn branches(&self, path: &str, opts: GitRequestOptions) -> Result<GitBranches> {
let result = self
.run("/runtime/v1/git/branches", repo_body(path, opts))
.await?;
Ok(GitBranches {
path: result.path.clone(),
branches: result.branches.clone(),
current_branch: result.current_branch.clone(),
result,
})
}
pub async fn create_branch(
&self,
path: &str,
branch: &str,
opts: GitRequestOptions,
) -> Result<GitCommandResult> {
let mut body = object_from(repo_body(path, opts));
body.insert("branch".into(), Value::String(branch.to_string()));
self.run("/runtime/v1/git/create_branch", Value::Object(body))
.await
}
pub async fn delete_branch(
&self,
path: &str,
branch: &str,
opts: GitDeleteBranchOptions,
) -> Result<GitCommandResult> {
let mut body = serde_json::Map::new();
body.insert("path".into(), Value::String(path.to_string()));
body.insert("branch".into(), Value::String(branch.to_string()));
if opts.force {
body.insert("force".into(), Value::Bool(true));
}
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
self.run("/runtime/v1/git/delete_branch", Value::Object(body))
.await
}
pub async fn add(&self, path: &str, opts: GitAddOptions) -> Result<GitCommandResult> {
let mut body = serde_json::Map::new();
body.insert("path".into(), Value::String(path.to_string()));
if !opts.files.is_empty() {
body.insert(
"files".into(),
Value::Array(opts.files.into_iter().map(Value::String).collect()),
);
}
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
self.run("/runtime/v1/git/add", Value::Object(body)).await
}
pub async fn commit(
&self,
path: &str,
message: &str,
opts: GitCommitOptions,
) -> Result<GitCommandResult> {
let mut body = serde_json::Map::new();
body.insert("path".into(), Value::String(path.to_string()));
body.insert("message".into(), Value::String(message.to_string()));
put_if_some_string(&mut body, "author_name", opts.author_name);
put_if_some_string(&mut body, "author_email", opts.author_email);
if opts.allow_empty {
body.insert("allow_empty".into(), Value::Bool(true));
}
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
self.run("/runtime/v1/git/commit", Value::Object(body))
.await
}
pub async fn pull(
&self,
path: &str,
opts: GitRemoteOperationOptions,
) -> Result<GitCommandResult> {
self.run(
"/runtime/v1/git/pull",
remote_operation_body(path, opts, false),
)
.await
}
pub async fn push(
&self,
path: &str,
opts: GitRemoteOperationOptions,
) -> Result<GitCommandResult> {
self.run(
"/runtime/v1/git/push",
remote_operation_body(path, opts, true),
)
.await
}
pub async fn checkout(
&self,
path: &str,
ref_name: &str,
opts: GitRequestOptions,
) -> Result<GitCommandResult> {
let mut body = object_from(repo_body(path, opts));
body.insert("ref".into(), Value::String(ref_name.to_string()));
self.run("/runtime/v1/git/checkout", Value::Object(body))
.await
}
pub async fn checkout_branch(
&self,
path: &str,
branch: &str,
opts: GitRequestOptions,
) -> Result<GitCommandResult> {
self.checkout(path, branch, opts).await
}
pub async fn remote_add(
&self,
path: &str,
name: &str,
url: &str,
opts: GitRemoteAddOptions,
) -> Result<GitCommandResult> {
let mut body = serde_json::Map::new();
body.insert("path".into(), Value::String(path.to_string()));
body.insert("name".into(), Value::String(name.to_string()));
body.insert("url".into(), Value::String(url.to_string()));
if opts.fetch {
body.insert("fetch".into(), Value::Bool(true));
}
if opts.overwrite {
body.insert("overwrite".into(), Value::Bool(true));
}
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
self.run("/runtime/v1/git/remote_add", Value::Object(body))
.await
}
pub async fn set_config(
&self,
key: &str,
value: &str,
opts: GitConfigOptions,
) -> Result<GitCommandResult> {
let mut body = config_body(key, opts);
body.insert("value".into(), Value::String(value.to_string()));
self.run("/runtime/v1/git/set_config", Value::Object(body))
.await
}
pub async fn get_config(&self, key: &str, opts: GitConfigOptions) -> Result<String> {
let body = config_body(key, opts);
let result = self
.run("/runtime/v1/git/get_config", Value::Object(body))
.await?;
Ok(result.value.unwrap_or_default())
}
async fn run(&self, path: &str, body: Value) -> Result<GitCommandResult> {
let payload = self.data_plane.post_json(path, body).await?;
Ok(git_result(payload.get("git").unwrap_or(&payload)))
}
}
fn repo_body(path: &str, opts: GitRequestOptions) -> Value {
let mut body = serde_json::Map::new();
body.insert("path".into(), Value::String(path.to_string()));
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
Value::Object(body)
}
fn remote_operation_body(path: &str, opts: GitRemoteOperationOptions, push: bool) -> Value {
let mut body = serde_json::Map::new();
body.insert("path".into(), Value::String(path.to_string()));
put_if_some_string(&mut body, "remote", opts.remote);
put_if_some_string(&mut body, "branch", opts.branch);
put_if_some_string(&mut body, "username", opts.username);
put_if_some_string(&mut body, "password", opts.password);
if push && opts.set_upstream {
body.insert("set_upstream".into(), Value::Bool(true));
}
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
Value::Object(body)
}
fn config_body(key: &str, opts: GitConfigOptions) -> serde_json::Map<String, Value> {
let mut body = serde_json::Map::new();
body.insert("key".into(), Value::String(key.to_string()));
put_if_some_string(&mut body, "scope", opts.scope);
put_if_some_string(&mut body, "path", opts.path);
put_request_options(&mut body, opts.envs, opts.timeout_seconds);
body
}
fn put_request_options(
body: &mut serde_json::Map<String, Value>,
envs: serde_json::Map<String, Value>,
timeout_seconds: Option<u64>,
) {
if !envs.is_empty() {
body.insert("env_vars".into(), Value::Object(envs));
}
put_if_some_u64(body, "timeout_seconds", timeout_seconds);
}
fn git_result(value: &Value) -> GitCommandResult {
GitCommandResult {
path: string_value(value, "path"),
url: string_value(value, "url"),
ref_name: string_value(value, "ref"),
branch: string_value(value, "branch"),
remote: string_value(value, "remote"),
name: string_value(value, "name"),
value: string_value(value, "value"),
branches: value
.get("branches")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default(),
current_branch: string_value(value, "current_branch"),
stdout: string_value(value, "stdout").unwrap_or_default(),
stderr: string_value(value, "stderr").unwrap_or_default(),
command: value.get("command").cloned(),
raw: value.clone(),
}
}
fn parse_status(result: GitCommandResult) -> GitStatus {
let mut status = GitStatus {
result,
..GitStatus::default()
};
for line in status.result.stdout.lines().filter(|line| !line.is_empty()) {
if let Some(branch_line) = line.strip_prefix("## ") {
status.detached = branch_line.contains("HEAD") && branch_line.contains("no branch");
if let Some((branch, tracking)) = branch_line.split_once("...") {
status.current_branch =
Some(branch.split(" [").next().unwrap_or(branch).to_string());
status.upstream = tracking
.split([' ', '['])
.next()
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
status.ahead = number_after(tracking, "ahead");
status.behind = number_after(tracking, "behind");
} else {
status.current_branch = Some(
branch_line
.split(" [")
.next()
.unwrap_or(branch_line)
.to_string(),
);
}
continue;
}
let index_status = line.chars().next().unwrap_or(' ');
let working_tree_status = line.chars().nth(1).unwrap_or(' ');
let name = line.get(3..).unwrap_or_default();
let (renamed_from, name) = name
.split_once(" -> ")
.map(|(from, to)| (Some(from.to_string()), to.to_string()))
.unwrap_or((None, name.to_string()));
status.file_status.push(GitFileStatus {
name,
status: status_name(index_status, working_tree_status).to_string(),
index_status: index_status.to_string(),
working_tree_status: working_tree_status.to_string(),
staged: index_status != ' ' && index_status != '?',
renamed_from,
});
}
status
}
fn number_after(value: &str, label: &str) -> u64 {
value
.split_once(&format!("{label} "))
.and_then(|(_, rest)| rest.split([',', ']']).next())
.and_then(|number| number.parse().ok())
.unwrap_or(0)
}
fn status_name(index_status: char, working_tree_status: char) -> &'static str {
match (index_status, working_tree_status) {
('?', '?') => "untracked",
('U', _) | (_, 'U') | ('A', 'A') => "conflict",
('D', _) | (_, 'D') => "deleted",
('R', _) => "renamed",
('A', _) => "added",
('M', _) | (_, 'M') => "modified",
_ => "changed",
}
}
fn string_value(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn put_if_some_string(map: &mut serde_json::Map<String, Value>, key: &str, value: Option<String>) {
if let Some(value) = value {
map.insert(key.to_string(), Value::String(value));
}
}
fn put_if_some_u64(map: &mut serde_json::Map<String, Value>, key: &str, value: Option<u64>) {
if let Some(value) = value {
map.insert(key.to_string(), Value::from(value));
}
}
fn object_from(value: Value) -> serde_json::Map<String, Value> {
value.as_object().cloned().unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{git_result, parse_status};
use serde_json::json;
#[test]
fn parses_short_branch_status() {
let result = git_result(&json!({
"path": "/workspace/repo",
"stdout": "## main...origin/main [ahead 1, behind 2]\n M a.txt\n?? b.txt\n",
"stderr": ""
}));
let status = parse_status(result);
assert_eq!(status.current_branch.as_deref(), Some("main"));
assert_eq!(status.upstream.as_deref(), Some("origin/main"));
assert_eq!(status.ahead, 1);
assert_eq!(status.behind, 2);
assert!(status.has_changes());
assert!(status.has_untracked());
assert_eq!(status.total_count(), 2);
}
}