Skip to main content

atman_runtime/tools/
git.rs

1use crate::error::RuntimeError;
2use crate::git;
3use crate::stream::StreamFrame;
4use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
5use crate::value::Value;
6
7pub struct GitDiff;
8
9pub struct GitInit;
10
11impl Tool for GitInit {
12    fn name(&self) -> &str {
13        "git.init"
14    }
15
16    fn tier(&self) -> Tier {
17        Tier::Two
18    }
19
20    fn description(&self) -> Option<&str> {
21        Some("Initialize a Git repository using libgit2, without spawning git.")
22    }
23
24    fn input_schema(&self) -> serde_json::Value {
25        serde_json::json!({
26            "type": "object",
27            "properties": {
28                "cwd": {"type": "string", "description": "Directory to initialize; defaults to the current directory."},
29                "initial_branch": {"type": "string", "description": "Initial branch name; uses libgit2's configured default when omitted."},
30                "bare": {"type": "boolean", "description": "Create a bare repository."}
31            }
32        })
33    }
34
35    fn invocation_provenance(
36        &self,
37        args: &ToolArgs,
38        ctx: &ToolCtx,
39    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
40        crate::tools::git_ops::git_mutation_provenance(args, ctx)
41    }
42
43    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
44        Box::pin(async move {
45            let explicit = match args.named("cwd") {
46                Some(Value::Path(path)) => Some(path.as_path()),
47                Some(Value::Str(path)) => Some(std::path::Path::new(path)),
48                Some(other) => {
49                    return Err(RuntimeError::TypeMismatch {
50                        expected: "string".into(),
51                        actual: other.kind_name().into(),
52                    });
53                }
54                None => None,
55            };
56            let path = ctx.resolve_cwd(explicit)?;
57            crate::fs_access::authorize_write(ctx, &path, self.name(), true).await?;
58            let bare = match args.named("bare") {
59                Some(Value::Bool(bare)) => *bare,
60                Some(other) => {
61                    return Err(RuntimeError::TypeMismatch {
62                        expected: "boolean".into(),
63                        actual: other.kind_name().into(),
64                    });
65                }
66                None => false,
67            };
68            let initial_branch = match args.named("initial_branch") {
69                Some(Value::Str(branch)) => Some(branch.as_str()),
70                Some(other) => {
71                    return Err(RuntimeError::TypeMismatch {
72                        expected: "string".into(),
73                        actual: other.kind_name().into(),
74                    });
75                }
76                None => None,
77            };
78            let info = git::init_repository(&path, bare, initial_branch)
79                .map_err(|e| RuntimeError::ToolFailed(format!("git.init: {e}")))?;
80            Ok(Value::Struct(vec![
81                ("path".into(), Value::Path(info.path)),
82                (
83                    "workdir".into(),
84                    info.workdir.map(Value::Path).unwrap_or(Value::Unit),
85                ),
86                ("git_dir".into(), Value::Path(info.git_dir)),
87                ("bare".into(), Value::Bool(info.bare)),
88            ]))
89        })
90    }
91}
92
93impl Tool for GitDiff {
94    fn name(&self) -> &str {
95        "git.diff"
96    }
97
98    fn tier(&self) -> Tier {
99        Tier::Zero
100    }
101
102    fn description(&self) -> Option<&str> {
103        Some(
104            "Return { diff, files } for the given git ref range so an LLM can receive only the \
105             changed text, not entire files. Optional `paths` narrows the diff to those \
106             pathspecs. Backed by libgit2 (read-only, no shell spawn).",
107        )
108    }
109
110    fn input_schema(&self) -> serde_json::Value {
111        serde_json::json!({
112            "type": "object",
113            "properties": {
114                "range": {"type": "string", "description": "git ref range, e.g. HEAD~3..HEAD"},
115                "paths": {"type": "array", "items": {"type": "string"}, "description": "optional path filter"},
116                "cwd": {"type": "string", "description": "optional working dir; defaults to atman's cwd"}
117            },
118            "required": ["range"]
119        })
120    }
121
122    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
123        Box::pin(async move {
124            let range = extract_string(&args, "range", 0)?;
125            let paths = extract_string_list(&args, "paths", 1).unwrap_or_default();
126            let explicit = args.named("cwd").and_then(|value| match value {
127                Value::Str(path) => Some(std::path::Path::new(path)),
128                _ => None,
129            });
130            let cwd = ctx.resolve_cwd(explicit)?;
131            let out = git::diff_range(&cwd, &range, &paths)
132                .map_err(|e| RuntimeError::ToolFailed(format!("git.diff: {e}")))?;
133            if let Some(tx) = &ctx.stream_tx {
134                let _ = tx.send(StreamFrame::DiffPreview {
135                    title: format!("git diff {range}"),
136                    tool_use_id: ctx.tool_use_id.clone(),
137                    old_content: None,
138                    new_content: None,
139                    unified_diff: Some(out.body.clone()),
140                    run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
141                });
142            }
143            Ok(Value::Struct(vec![
144                ("diff".into(), Value::Str(out.body)),
145                (
146                    "files".into(),
147                    Value::List(out.files.into_iter().map(Value::Str).collect()),
148                ),
149            ]))
150        })
151    }
152}
153
154fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
155    let value = match args.named(name) {
156        Some(v) => v,
157        None => args.positional(pos)?,
158    };
159    match value {
160        Value::Str(s) => Ok(s.clone()),
161        other => Err(RuntimeError::TypeMismatch {
162            expected: "string".into(),
163            actual: other.kind_name().into(),
164        }),
165    }
166}
167
168fn extract_string_list(
169    args: &ToolArgs,
170    name: &str,
171    pos: usize,
172) -> Result<Vec<String>, RuntimeError> {
173    let value = match args.named(name) {
174        Some(v) => v,
175        None => match args.positional(pos) {
176            Ok(v) => v,
177            Err(_) => return Ok(Vec::new()),
178        },
179    };
180    match value {
181        Value::List(items) => {
182            let mut out = Vec::with_capacity(items.len());
183            for it in items {
184                match it {
185                    Value::Str(s) => out.push(s.clone()),
186                    other => {
187                        return Err(RuntimeError::TypeMismatch {
188                            expected: "list of string".into(),
189                            actual: other.kind_name().into(),
190                        });
191                    }
192                }
193            }
194            Ok(out)
195        }
196        Value::Unit => Ok(Vec::new()),
197        other => Err(RuntimeError::TypeMismatch {
198            expected: "list of string".into(),
199            actual: other.kind_name().into(),
200        }),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::git::GitCli;
208    use std::path::Path;
209
210    fn have_git() -> bool {
211        GitCli::ensure_available().is_ok()
212    }
213
214    fn seed_two_commits(dir: &Path) {
215        let cli = GitCli::at(dir);
216        cli.init("main").unwrap();
217        for (k, v) in [
218            ("user.email", "t@atman.local"),
219            ("user.name", "atman test"),
220            ("commit.gpgsign", "false"),
221        ] {
222            cli.run(&["config", k, v]).unwrap();
223        }
224        std::fs::write(dir.join("a.txt"), "line one\n").unwrap();
225        std::fs::write(dir.join("b.txt"), "b\n").unwrap();
226        cli.add_all().unwrap();
227        cli.commit("initial").unwrap();
228        std::fs::write(dir.join("a.txt"), "line one\nline two\n").unwrap();
229        std::fs::write(dir.join("c.txt"), "new file\n").unwrap();
230        cli.add_all().unwrap();
231        cli.commit("second").unwrap();
232    }
233
234    #[tokio::test]
235    async fn diff_returns_body_and_files() {
236        if !have_git() {
237            eprintln!("skip: git not on PATH");
238            return;
239        }
240        let tmp = tempfile::tempdir().unwrap();
241        seed_two_commits(tmp.path());
242        let ctx = ToolCtx::new();
243        let args = ToolArgs {
244            positional: vec![Value::Str("HEAD~1..HEAD".into())],
245            named: vec![(
246                "cwd".into(),
247                Value::Str(tmp.path().to_string_lossy().into()),
248            )],
249        };
250        let v = GitDiff.call(args, &ctx).await.unwrap();
251        let Value::Struct(fields) = v else {
252            panic!("expected struct, got {v:?}");
253        };
254        let diff = fields
255            .iter()
256            .find(|(k, _)| k == "diff")
257            .and_then(|(_, v)| {
258                if let Value::Str(s) = v {
259                    Some(s.clone())
260                } else {
261                    None
262                }
263            })
264            .unwrap();
265        assert!(diff.contains("+line two"), "want addition, got:\n{diff}");
266        assert!(diff.contains("+new file"), "want new file:\n{diff}");
267        let files = fields
268            .iter()
269            .find(|(k, _)| k == "files")
270            .and_then(|(_, v)| {
271                if let Value::List(xs) = v {
272                    Some(xs.clone())
273                } else {
274                    None
275                }
276            })
277            .unwrap();
278        let names: Vec<String> = files
279            .into_iter()
280            .filter_map(|v| if let Value::Str(s) = v { Some(s) } else { None })
281            .collect();
282        assert!(names.contains(&"a.txt".to_string()), "files={names:?}");
283        assert!(names.contains(&"c.txt".to_string()), "files={names:?}");
284    }
285
286    #[tokio::test]
287    async fn diff_paths_filter_narrows() {
288        if !have_git() {
289            eprintln!("skip");
290            return;
291        }
292        let tmp = tempfile::tempdir().unwrap();
293        seed_two_commits(tmp.path());
294        let ctx = ToolCtx::new();
295        let args = ToolArgs {
296            positional: vec![Value::Str("HEAD~1..HEAD".into())],
297            named: vec![
298                (
299                    "cwd".into(),
300                    Value::Str(tmp.path().to_string_lossy().into()),
301                ),
302                (
303                    "paths".into(),
304                    Value::List(vec![Value::Str("a.txt".into())]),
305                ),
306            ],
307        };
308        let v = GitDiff.call(args, &ctx).await.unwrap();
309        let Value::Struct(fields) = v else {
310            panic!("struct");
311        };
312        let files = fields
313            .iter()
314            .find(|(k, _)| k == "files")
315            .and_then(|(_, v)| {
316                if let Value::List(xs) = v {
317                    Some(xs.clone())
318                } else {
319                    None
320                }
321            })
322            .unwrap();
323        let names: Vec<String> = files
324            .into_iter()
325            .filter_map(|v| if let Value::Str(s) = v { Some(s) } else { None })
326            .collect();
327        assert_eq!(names, vec!["a.txt".to_string()], "files={names:?}");
328    }
329
330    #[tokio::test]
331    async fn diff_outside_git_repo_errors() {
332        let tmp = tempfile::tempdir().unwrap();
333        let ctx = ToolCtx::new();
334        let args = ToolArgs {
335            positional: vec![Value::Str("HEAD".into())],
336            named: vec![(
337                "cwd".into(),
338                Value::Str(tmp.path().to_string_lossy().into()),
339            )],
340        };
341        let err = GitDiff.call(args, &ctx).await.unwrap_err();
342        let msg = format!("{err}");
343        assert!(
344            msg.contains("not a git repository"),
345            "want repo error: {msg}"
346        );
347    }
348
349    #[tokio::test]
350    async fn diff_invalid_range_errors() {
351        if !have_git() {
352            eprintln!("skip");
353            return;
354        }
355        let tmp = tempfile::tempdir().unwrap();
356        seed_two_commits(tmp.path());
357        let ctx = ToolCtx::new();
358        let args = ToolArgs {
359            positional: vec![Value::Str("nope_ref..other_nope".into())],
360            named: vec![(
361                "cwd".into(),
362                Value::Str(tmp.path().to_string_lossy().into()),
363            )],
364        };
365        let err = GitDiff.call(args, &ctx).await.unwrap_err();
366        let msg = format!("{err}");
367        assert!(
368            msg.contains("libgit2") || msg.contains("revspec"),
369            "err={msg}"
370        );
371    }
372}