use crate::harness::{ExecutionPolicy, NetworkPolicy, SandboxPolicy};
use crate::{RunContext, RunMetadata};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use super::command::{CommandExecutor, CommandOutputLimit, CommandRequest, CommandStatus};
use super::workspace::{WorkspaceDiff, WorkspacePath};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GitOperation {
Status(GitStatusRequest),
Diff(GitDiffRequest),
ChangedFiles(GitChangedFilesRequest),
Head,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitStatusRequest {
pub include_branch: bool,
}
impl Default for GitStatusRequest {
fn default() -> Self {
Self {
include_branch: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitDiffRequest {
pub paths: Vec<WorkspacePath>,
pub staged: bool,
pub max_bytes: usize,
}
impl Default for GitDiffRequest {
fn default() -> Self {
Self {
paths: Vec::new(),
staged: false,
max_bytes: 256 * 1024,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitChangedFilesRequest {
pub include_untracked: bool,
}
impl Default for GitChangedFilesRequest {
fn default() -> Self {
Self {
include_untracked: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitStatus {
pub branch: Option<String>,
pub changed_files: Vec<GitChangedFile>,
pub raw: String,
pub truncated: bool,
pub metadata: RunMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitChangedFile {
pub path: WorkspacePath,
pub status: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHead {
pub commit: String,
pub branch: Option<String>,
}
#[async_trait]
pub trait GitInspector: Send + Sync {
async fn status(
&self,
request: GitStatusRequest,
context: &RunContext,
) -> Result<GitStatus, GitError>;
async fn diff(
&self,
request: GitDiffRequest,
context: &RunContext,
) -> Result<WorkspaceDiff, GitError>;
async fn changed_files(
&self,
request: GitChangedFilesRequest,
context: &RunContext,
) -> Result<Vec<GitChangedFile>, GitError>;
async fn head(&self, context: &RunContext) -> Result<Option<GitHead>, GitError>;
}
#[derive(Debug, Clone)]
pub struct CliGitInspector<C> {
commands: C,
timeout: Duration,
}
impl<C> CliGitInspector<C> {
pub fn new(commands: C) -> Self {
Self {
commands,
timeout: Duration::from_secs(10),
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
#[async_trait]
impl<C> GitInspector for CliGitInspector<C>
where
C: CommandExecutor,
{
async fn status(
&self,
request: GitStatusRequest,
context: &RunContext,
) -> Result<GitStatus, GitError> {
let output = self
.run_git(["status", "--porcelain=v1", "-b"], context)
.await?;
let mut branch = None;
let mut changed = Vec::new();
for line in output.stdout.text.lines() {
if line.starts_with("## ") {
if request.include_branch {
branch = Some(line.trim_start_matches("## ").to_string());
}
continue;
}
if line.len() < 4 {
continue;
}
let status = line[..2].to_string();
let raw_path = line[3..].trim();
let path_text = raw_path
.rsplit_once(" -> ")
.map(|(_, to)| to)
.unwrap_or(raw_path)
.trim_matches('"');
if let Ok(path) = WorkspacePath::parse(path_text) {
changed.push(GitChangedFile { path, status });
}
}
Ok(GitStatus {
branch,
changed_files: changed,
raw: output.stdout.text,
truncated: output.truncated,
metadata: output.metadata,
})
}
async fn diff(
&self,
request: GitDiffRequest,
context: &RunContext,
) -> Result<WorkspaceDiff, GitError> {
let mut argv = vec!["diff".to_string(), "--no-color".to_string()];
if request.staged {
argv.push("--cached".to_string());
}
if !request.paths.is_empty() {
argv.push("--".to_string());
argv.extend(request.paths.iter().map(WorkspacePath::display));
}
let mut command = CommandRequest::new(std::iter::once("git".to_string()).chain(argv));
command.timeout = Some(self.timeout);
command.output_limit = CommandOutputLimit {
stdout_bytes: request.max_bytes,
stderr_bytes: 64 * 1024,
};
let output = self.run(command, context).await?;
Ok(WorkspaceDiff {
changed_files: request.paths,
text: output.stdout.text,
truncated: output.truncated,
metadata: output.metadata,
})
}
async fn changed_files(
&self,
request: GitChangedFilesRequest,
context: &RunContext,
) -> Result<Vec<GitChangedFile>, GitError> {
let status = self.status(GitStatusRequest::default(), context).await?;
Ok(status
.changed_files
.into_iter()
.filter(|file| request.include_untracked || file.status != "??")
.collect())
}
async fn head(&self, context: &RunContext) -> Result<Option<GitHead>, GitError> {
let commit = self
.run_git(["rev-parse", "HEAD"], context)
.await?
.stdout
.text
.trim()
.to_string();
if commit.is_empty() {
return Ok(None);
}
let branch_output = self.run_git(["branch", "--show-current"], context).await?;
let branch = match branch_output.stdout.text.trim() {
"" => None,
branch => Some(branch.to_string()),
};
Ok(Some(GitHead { commit, branch }))
}
}
impl<C> CliGitInspector<C>
where
C: CommandExecutor,
{
async fn run_git<I, S>(
&self,
args: I,
context: &RunContext,
) -> Result<super::command::CommandOutput, GitError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let argv = std::iter::once("git".to_string())
.chain(args.into_iter().map(Into::into))
.collect::<Vec<_>>();
self.run(CommandRequest::new(argv), context).await
}
async fn run(
&self,
mut command: CommandRequest,
context: &RunContext,
) -> Result<super::command::CommandOutput, GitError> {
command.timeout = command.timeout.or(Some(self.timeout));
command.requested_network = Some(NetworkPolicy::Deny);
let output = self
.commands
.execute(
command,
&ExecutionPolicy::new(SandboxPolicy::ReadOnly, NetworkPolicy::Deny)
.with_timeout(Some(self.timeout)),
context,
)
.await
.map_err(|error| GitError::Command {
message: error.to_string(),
})?;
match output.status {
CommandStatus::Exited { code: 0 } => Ok(output),
_ => Err(GitError::Command {
message: format!("git command failed: {}", output.stderr.text),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GitError {
#[error("git command error: {message}")]
Command {
message: String,
},
#[error("git parse error: {message}")]
Parse {
message: String,
},
#[error("unsupported git operation: {message}")]
Unsupported {
message: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_status_path() {
let line = " M src/lib.rs";
let status = line[..2].to_string();
let path = WorkspacePath::parse(line[3..].trim()).unwrap();
assert_eq!(status, " M");
assert_eq!(path.display(), "src/lib.rs");
}
}