use crate::services::docker::CommandExecutor;
use anyhow::{Context, Result};
use duct::cmd;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub fn get_current_branch() -> Result<String> {
let branch = cmd!("git", "branch", "--show-current")
.read()
.context("Failed to execute git branch --show-current")?;
let branch = branch.trim();
if branch.is_empty() {
anyhow::bail!("Not on a branch (detached HEAD?)");
}
Ok(branch.to_string())
}
pub fn extract_agent_id(branch: &str) -> Option<String> {
branch
.strip_prefix("gh-")
.and_then(|s| s.split('/').next())
.filter(|id| !id.is_empty())
.map(|id| format!("gh-{}", id))
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct Commit {
pub hash: String,
pub message: String,
pub author: String,
pub date: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct WorktreeInfo {
pub path: String,
pub branch: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
pub struct RepoInfo {
pub branch: String,
pub owner: Option<String>,
pub name: Option<String>,
}
#[derive(Clone)]
pub struct GitService {
executor: Arc<dyn CommandExecutor>,
}
impl GitService {
pub fn new(executor: Arc<dyn CommandExecutor>) -> Self {
Self { executor }
}
#[tracing::instrument(skip(self), fields(cmd = ?args))]
async fn exec_git(&self, dir: &str, args: &[&str]) -> Result<String> {
let mut cmd = vec!["git"];
cmd.extend_from_slice(args);
self.executor.exec(dir, &cmd).await
}
#[tracing::instrument(skip(self))]
pub async fn get_branch(&self, dir: &str) -> Result<String> {
let output = self
.exec_git(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
.await?;
Ok(output.trim().to_string())
}
#[tracing::instrument(skip(self))]
pub async fn get_worktree(&self, dir: &str) -> Result<WorktreeInfo> {
let path = self
.exec_git(dir, &["rev-parse", "--show-toplevel"])
.await?;
let branch = self.get_branch(dir).await?;
Ok(WorktreeInfo {
path: path.trim().to_string(),
branch,
})
}
#[tracing::instrument(skip(self))]
pub async fn get_dirty_files(&self, dir: &str) -> Result<Vec<String>> {
let output = self.exec_git(dir, &["status", "--porcelain"]).await?;
Ok(output.lines().map(|l| l.to_string()).collect())
}
#[tracing::instrument(skip(self))]
pub async fn get_recent_commits(&self, dir: &str, n: u32) -> Result<Vec<Commit>> {
let format = "%H|%an|%ad|%s";
let output = self
.exec_git(
dir,
&[
"log",
&format!("-n{}", n),
&format!("--format={}", format),
"--date=unix",
],
)
.await?;
let mut commits = Vec::new();
for line in output.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 4 {
commits.push(Commit {
hash: parts[0].to_string(),
message: parts[3..].join("|"),
author: parts[1].to_string(),
date: parts[2].to_string(),
});
}
}
Ok(commits)
}
#[tracing::instrument(skip(self))]
pub async fn has_unpushed_commits(&self, dir: &str) -> Result<u32> {
let output = self
.exec_git(dir, &["rev-list", "--count", "@{upstream}..HEAD"])
.await;
match output {
Ok(count_str) => {
let count = count_str.trim().parse::<u32>().unwrap_or(0);
tracing::debug!(count, "Unpushed commits count");
Ok(count)
}
Err(e) => {
tracing::warn!(
error = %e,
"Failed to check unpushed commits. Assuming no upstream."
);
Ok(0)
}
}
}
#[tracing::instrument(skip(self))]
pub async fn get_remote_url(&self, dir: &str) -> Result<String> {
let output = self.exec_git(dir, &["remote", "get-url", "origin"]).await?;
Ok(output.trim().to_string())
}
#[tracing::instrument(skip(self))]
pub async fn get_repo_info(&self, dir: &str) -> Result<RepoInfo> {
let branch = self.get_branch(dir).await?;
let remote_url = self.get_remote_url(dir).await.ok();
let (owner, name) = remote_url
.as_ref()
.and_then(|url| parse_github_url(url))
.unzip();
Ok(RepoInfo {
branch,
owner,
name,
})
}
}
pub fn parse_github_url(url: &str) -> Option<(String, String)> {
let cleaned = url
.replace("git@github.com:", "https://github.com/")
.replace(".git", "");
let parts: Vec<&str> = cleaned.split('/').collect();
match parts.as_slice() {
[.., owner, repo] if !owner.is_empty() && !repo.is_empty() => {
Some((owner.to_string(), repo.to_string()))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
struct MockExecutor {
responses: Mutex<Vec<Result<String>>>,
}
impl MockExecutor {
fn new(responses: Vec<Result<String>>) -> Self {
Self {
responses: Mutex::new(responses),
}
}
}
impl CommandExecutor for MockExecutor {
fn exec<'a>(
&'a self,
_dir: &'a str,
_cmd: &'a [&'a str],
) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
let response = self.responses.lock().unwrap().remove(0);
Box::pin(async move { response })
}
}
#[tokio::test]
async fn test_get_branch() {
let mock = Arc::new(MockExecutor::new(vec![Ok("main\n".to_string())]));
let git = GitService::new(mock);
let branch = git.get_branch("/app").await.unwrap();
assert_eq!(branch, "main");
}
#[tokio::test]
async fn test_get_worktree() {
let mock = Arc::new(MockExecutor::new(vec![
Ok("/app\n".to_string()), Ok("feature/123\n".to_string()), ]));
let git = GitService::new(mock);
let wt = git.get_worktree("/app/src").await.unwrap();
assert_eq!(wt.path, "/app");
assert_eq!(wt.branch, "feature/123");
}
#[tokio::test]
async fn test_get_commits() {
let log_output =
"hash1|Author One|2023-01-01|Message 1\nhash2|Author Two|2023-01-02|Message 2\n";
let mock = Arc::new(MockExecutor::new(vec![Ok(log_output.to_string())]));
let git = GitService::new(mock);
let commits = git.get_recent_commits("/app", 2).await.unwrap();
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].hash, "hash1");
assert_eq!(commits[1].message, "Message 2");
}
#[test]
fn test_extract_agent_id_valid_branches() {
assert_eq!(
extract_agent_id("gh-123/feat-add-sidebar"),
Some("gh-123".to_string())
);
assert_eq!(
extract_agent_id("gh-456/fix-bug-with-events"),
Some("gh-456".to_string())
);
assert_eq!(extract_agent_id("gh-789"), Some("gh-789".to_string()));
assert_eq!(
extract_agent_id("gh-111/feat/nested/path"),
Some("gh-111".to_string())
);
}
#[test]
fn test_extract_agent_id_invalid_branches() {
assert_eq!(extract_agent_id("main"), None);
assert_eq!(extract_agent_id("develop"), None);
assert_eq!(extract_agent_id("feature/something"), None);
assert_eq!(extract_agent_id("gh-"), None);
assert_eq!(extract_agent_id("gh-/no-number"), None);
assert_eq!(extract_agent_id(""), None);
}
}