1use crate::harness::{ExecutionPolicy, NetworkPolicy, SandboxPolicy};
2use crate::{RunContext, RunMetadata};
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6
7use super::command::{CommandExecutor, CommandOutputLimit, CommandRequest, CommandStatus};
8use super::workspace::{WorkspaceDiff, WorkspacePath};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[non_exhaustive]
13pub enum GitOperation {
14 Status(GitStatusRequest),
16 Diff(GitDiffRequest),
18 ChangedFiles(GitChangedFilesRequest),
20 Head,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct GitStatusRequest {
27 pub include_branch: bool,
29}
30
31impl Default for GitStatusRequest {
32 fn default() -> Self {
33 Self {
34 include_branch: true,
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct GitDiffRequest {
42 pub paths: Vec<WorkspacePath>,
44 pub staged: bool,
46 pub max_bytes: usize,
48}
49
50impl Default for GitDiffRequest {
51 fn default() -> Self {
52 Self {
53 paths: Vec::new(),
54 staged: false,
55 max_bytes: 256 * 1024,
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct GitChangedFilesRequest {
63 pub include_untracked: bool,
65}
66
67impl Default for GitChangedFilesRequest {
68 fn default() -> Self {
69 Self {
70 include_untracked: true,
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct GitStatus {
78 pub branch: Option<String>,
80 pub changed_files: Vec<GitChangedFile>,
82 pub raw: String,
84 pub truncated: bool,
86 pub metadata: RunMetadata,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct GitChangedFile {
93 pub path: WorkspacePath,
95 pub status: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct GitHead {
102 pub commit: String,
104 pub branch: Option<String>,
106}
107
108#[async_trait]
110pub trait GitInspector: Send + Sync {
111 async fn status(
113 &self,
114 request: GitStatusRequest,
115 context: &RunContext,
116 ) -> Result<GitStatus, GitError>;
117
118 async fn diff(
120 &self,
121 request: GitDiffRequest,
122 context: &RunContext,
123 ) -> Result<WorkspaceDiff, GitError>;
124
125 async fn changed_files(
127 &self,
128 request: GitChangedFilesRequest,
129 context: &RunContext,
130 ) -> Result<Vec<GitChangedFile>, GitError>;
131
132 async fn head(&self, context: &RunContext) -> Result<Option<GitHead>, GitError>;
134}
135
136#[derive(Debug, Clone)]
138pub struct CliGitInspector<C> {
139 commands: C,
140 timeout: Duration,
141}
142
143impl<C> CliGitInspector<C> {
144 pub fn new(commands: C) -> Self {
146 Self {
147 commands,
148 timeout: Duration::from_secs(10),
149 }
150 }
151
152 pub fn with_timeout(mut self, timeout: Duration) -> Self {
154 self.timeout = timeout;
155 self
156 }
157}
158
159#[async_trait]
160impl<C> GitInspector for CliGitInspector<C>
161where
162 C: CommandExecutor,
163{
164 async fn status(
165 &self,
166 request: GitStatusRequest,
167 context: &RunContext,
168 ) -> Result<GitStatus, GitError> {
169 let output = self
170 .run_git(["status", "--porcelain=v1", "-b"], context)
171 .await?;
172 let mut branch = None;
173 let mut changed = Vec::new();
174 for line in output.stdout.text.lines() {
175 if line.starts_with("## ") {
176 if request.include_branch {
177 branch = Some(line.trim_start_matches("## ").to_string());
178 }
179 continue;
180 }
181 if line.len() < 4 {
182 continue;
183 }
184 let status = line[..2].to_string();
185 let raw_path = line[3..].trim();
186 let path_text = raw_path
187 .rsplit_once(" -> ")
188 .map(|(_, to)| to)
189 .unwrap_or(raw_path)
190 .trim_matches('"');
191 if let Ok(path) = WorkspacePath::parse(path_text) {
192 changed.push(GitChangedFile { path, status });
193 }
194 }
195 Ok(GitStatus {
196 branch,
197 changed_files: changed,
198 raw: output.stdout.text,
199 truncated: output.truncated,
200 metadata: output.metadata,
201 })
202 }
203
204 async fn diff(
205 &self,
206 request: GitDiffRequest,
207 context: &RunContext,
208 ) -> Result<WorkspaceDiff, GitError> {
209 let mut argv = vec!["diff".to_string(), "--no-color".to_string()];
210 if request.staged {
211 argv.push("--cached".to_string());
212 }
213 if !request.paths.is_empty() {
214 argv.push("--".to_string());
215 argv.extend(request.paths.iter().map(WorkspacePath::display));
216 }
217 let mut command = CommandRequest::new(std::iter::once("git".to_string()).chain(argv));
218 command.timeout = Some(self.timeout);
219 command.output_limit = CommandOutputLimit {
220 stdout_bytes: request.max_bytes,
221 stderr_bytes: 64 * 1024,
222 };
223 let output = self.run(command, context).await?;
224 Ok(WorkspaceDiff {
225 changed_files: request.paths,
226 text: output.stdout.text,
227 truncated: output.truncated,
228 metadata: output.metadata,
229 })
230 }
231
232 async fn changed_files(
233 &self,
234 request: GitChangedFilesRequest,
235 context: &RunContext,
236 ) -> Result<Vec<GitChangedFile>, GitError> {
237 let status = self.status(GitStatusRequest::default(), context).await?;
238 Ok(status
239 .changed_files
240 .into_iter()
241 .filter(|file| request.include_untracked || file.status != "??")
242 .collect())
243 }
244
245 async fn head(&self, context: &RunContext) -> Result<Option<GitHead>, GitError> {
246 let commit = self
247 .run_git(["rev-parse", "HEAD"], context)
248 .await?
249 .stdout
250 .text
251 .trim()
252 .to_string();
253 if commit.is_empty() {
254 return Ok(None);
255 }
256 let branch_output = self.run_git(["branch", "--show-current"], context).await?;
257 let branch = match branch_output.stdout.text.trim() {
258 "" => None,
259 branch => Some(branch.to_string()),
260 };
261 Ok(Some(GitHead { commit, branch }))
262 }
263}
264
265impl<C> CliGitInspector<C>
266where
267 C: CommandExecutor,
268{
269 async fn run_git<I, S>(
270 &self,
271 args: I,
272 context: &RunContext,
273 ) -> Result<super::command::CommandOutput, GitError>
274 where
275 I: IntoIterator<Item = S>,
276 S: Into<String>,
277 {
278 let argv = std::iter::once("git".to_string())
279 .chain(args.into_iter().map(Into::into))
280 .collect::<Vec<_>>();
281 self.run(CommandRequest::new(argv), context).await
282 }
283
284 async fn run(
285 &self,
286 mut command: CommandRequest,
287 context: &RunContext,
288 ) -> Result<super::command::CommandOutput, GitError> {
289 command.timeout = command.timeout.or(Some(self.timeout));
290 command.requested_network = Some(NetworkPolicy::Deny);
291 let output = self
292 .commands
293 .execute(
294 command,
295 &ExecutionPolicy::new(SandboxPolicy::ReadOnly, NetworkPolicy::Deny)
296 .with_timeout(Some(self.timeout)),
297 context,
298 )
299 .await
300 .map_err(|error| GitError::Command {
301 message: error.to_string(),
302 })?;
303 match output.status {
304 CommandStatus::Exited { code: 0 } => Ok(output),
305 _ => Err(GitError::Command {
306 message: format!("git command failed: {}", output.stderr.text),
307 }),
308 }
309 }
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
314#[non_exhaustive]
315pub enum GitError {
316 #[error("git command error: {message}")]
318 Command {
319 message: String,
321 },
322 #[error("git parse error: {message}")]
324 Parse {
325 message: String,
327 },
328 #[error("unsupported git operation: {message}")]
330 Unsupported {
331 message: String,
333 },
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn parses_status_path() {
342 let line = " M src/lib.rs";
343 let status = line[..2].to_string();
344 let path = WorkspacePath::parse(line[3..].trim()).unwrap();
345 assert_eq!(status, " M");
346 assert_eq!(path.display(), "src/lib.rs");
347 }
348}