Skip to main content

atman_runtime/tools/
git.rs

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