Skip to main content

molo_coding/coding/
git.rs

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/// Read-only git operation for typed git effects.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[non_exhaustive]
13pub enum GitOperation {
14    /// `git status --porcelain=v1 -b`.
15    Status(GitStatusRequest),
16    /// `git diff`.
17    Diff(GitDiffRequest),
18    /// Changed files derived from status.
19    ChangedFiles(GitChangedFilesRequest),
20    /// Current HEAD.
21    Head,
22}
23
24/// Git status request.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct GitStatusRequest {
27    /// Include branch header.
28    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/// Git diff request.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct GitDiffRequest {
42    /// Paths to diff. Empty means all paths.
43    pub paths: Vec<WorkspacePath>,
44    /// Whether staged changes should be diffed.
45    pub staged: bool,
46    /// Maximum diff bytes.
47    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/// Changed-files request.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct GitChangedFilesRequest {
63    /// Include untracked files.
64    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/// Parsed git status.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct GitStatus {
78    /// Branch header, when requested and available.
79    pub branch: Option<String>,
80    /// Changed files.
81    pub changed_files: Vec<GitChangedFile>,
82    /// Raw status text after output limiting.
83    pub raw: String,
84    /// Whether raw output was truncated.
85    pub truncated: bool,
86    /// Host-owned metadata.
87    pub metadata: RunMetadata,
88}
89
90/// Changed git file.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct GitChangedFile {
93    /// Workspace path.
94    pub path: WorkspacePath,
95    /// Two-character porcelain status.
96    pub status: String,
97}
98
99/// Current git head.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct GitHead {
102    /// Commit hash.
103    pub commit: String,
104    /// Current branch name, when available.
105    pub branch: Option<String>,
106}
107
108/// Read-only git inspector.
109#[async_trait]
110pub trait GitInspector: Send + Sync {
111    /// Returns git status.
112    async fn status(
113        &self,
114        request: GitStatusRequest,
115        context: &RunContext,
116    ) -> Result<GitStatus, GitError>;
117
118    /// Returns git diff as a workspace diff summary.
119    async fn diff(
120        &self,
121        request: GitDiffRequest,
122        context: &RunContext,
123    ) -> Result<WorkspaceDiff, GitError>;
124
125    /// Returns changed files from git status.
126    async fn changed_files(
127        &self,
128        request: GitChangedFilesRequest,
129        context: &RunContext,
130    ) -> Result<Vec<GitChangedFile>, GitError>;
131
132    /// Returns current git head.
133    async fn head(&self, context: &RunContext) -> Result<Option<GitHead>, GitError>;
134}
135
136/// Git inspector implemented by invoking read-only git commands.
137#[derive(Debug, Clone)]
138pub struct CliGitInspector<C> {
139    commands: C,
140    timeout: Duration,
141}
142
143impl<C> CliGitInspector<C> {
144    /// Constructs a git inspector from a command executor.
145    pub fn new(commands: C) -> Self {
146        Self {
147            commands,
148            timeout: Duration::from_secs(10),
149        }
150    }
151
152    /// Sets command timeout.
153    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/// Git inspection errors.
313#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
314#[non_exhaustive]
315pub enum GitError {
316    /// Git command failed.
317    #[error("git command error: {message}")]
318    Command {
319        /// Model-safe explanation.
320        message: String,
321    },
322    /// Git output could not be parsed.
323    #[error("git parse error: {message}")]
324    Parse {
325        /// Model-safe explanation.
326        message: String,
327    },
328    /// Git operation is unsupported.
329    #[error("unsupported git operation: {message}")]
330    Unsupported {
331        /// Model-safe explanation.
332        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}