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                    run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
58                });
59            }
60            Ok(Value::Struct(vec![
61                ("diff".into(), Value::Str(out.body)),
62                (
63                    "files".into(),
64                    Value::List(out.files.into_iter().map(Value::Str).collect()),
65                ),
66            ]))
67        })
68    }
69}
70
71fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
72    let value = match args.named(name) {
73        Some(v) => v,
74        None => args.positional(pos)?,
75    };
76    match value {
77        Value::Str(s) => Ok(s.clone()),
78        other => Err(RuntimeError::TypeMismatch {
79            expected: "string".into(),
80            actual: other.kind_name().into(),
81        }),
82    }
83}
84
85fn extract_string_list(
86    args: &ToolArgs,
87    name: &str,
88    pos: usize,
89) -> Result<Vec<String>, RuntimeError> {
90    let value = match args.named(name) {
91        Some(v) => v,
92        None => match args.positional(pos) {
93            Ok(v) => v,
94            Err(_) => return Ok(Vec::new()),
95        },
96    };
97    match value {
98        Value::List(items) => {
99            let mut out = Vec::with_capacity(items.len());
100            for it in items {
101                match it {
102                    Value::Str(s) => out.push(s.clone()),
103                    other => {
104                        return Err(RuntimeError::TypeMismatch {
105                            expected: "list of string".into(),
106                            actual: other.kind_name().into(),
107                        });
108                    }
109                }
110            }
111            Ok(out)
112        }
113        Value::Unit => Ok(Vec::new()),
114        other => Err(RuntimeError::TypeMismatch {
115            expected: "list of string".into(),
116            actual: other.kind_name().into(),
117        }),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::git::GitCli;
125    use std::path::Path;
126
127    fn have_git() -> bool {
128        GitCli::ensure_available().is_ok()
129    }
130
131    fn seed_two_commits(dir: &Path) {
132        let cli = GitCli::at(dir);
133        cli.init("main").unwrap();
134        for (k, v) in [
135            ("user.email", "t@atman.local"),
136            ("user.name", "atman test"),
137            ("commit.gpgsign", "false"),
138        ] {
139            cli.run(&["config", k, v]).unwrap();
140        }
141        std::fs::write(dir.join("a.txt"), "line one\n").unwrap();
142        std::fs::write(dir.join("b.txt"), "b\n").unwrap();
143        cli.add_all().unwrap();
144        cli.commit("initial").unwrap();
145        std::fs::write(dir.join("a.txt"), "line one\nline two\n").unwrap();
146        std::fs::write(dir.join("c.txt"), "new file\n").unwrap();
147        cli.add_all().unwrap();
148        cli.commit("second").unwrap();
149    }
150
151    #[tokio::test]
152    async fn diff_returns_body_and_files() {
153        if !have_git() {
154            eprintln!("skip: git not on PATH");
155            return;
156        }
157        let tmp = tempfile::tempdir().unwrap();
158        seed_two_commits(tmp.path());
159        let ctx = ToolCtx::new();
160        let args = ToolArgs {
161            positional: vec![Value::Str("HEAD~1..HEAD".into())],
162            named: vec![(
163                "cwd".into(),
164                Value::Str(tmp.path().to_string_lossy().into()),
165            )],
166        };
167        let v = GitDiff.call(args, &ctx).await.unwrap();
168        let Value::Struct(fields) = v else {
169            panic!("expected struct, got {v:?}");
170        };
171        let diff = fields
172            .iter()
173            .find(|(k, _)| k == "diff")
174            .and_then(|(_, v)| {
175                if let Value::Str(s) = v {
176                    Some(s.clone())
177                } else {
178                    None
179                }
180            })
181            .unwrap();
182        assert!(diff.contains("+line two"), "want addition, got:\n{diff}");
183        assert!(diff.contains("+new file"), "want new file:\n{diff}");
184        let files = fields
185            .iter()
186            .find(|(k, _)| k == "files")
187            .and_then(|(_, v)| {
188                if let Value::List(xs) = v {
189                    Some(xs.clone())
190                } else {
191                    None
192                }
193            })
194            .unwrap();
195        let names: Vec<String> = files
196            .into_iter()
197            .filter_map(|v| if let Value::Str(s) = v { Some(s) } else { None })
198            .collect();
199        assert!(names.contains(&"a.txt".to_string()), "files={names:?}");
200        assert!(names.contains(&"c.txt".to_string()), "files={names:?}");
201    }
202
203    #[tokio::test]
204    async fn diff_paths_filter_narrows() {
205        if !have_git() {
206            eprintln!("skip");
207            return;
208        }
209        let tmp = tempfile::tempdir().unwrap();
210        seed_two_commits(tmp.path());
211        let ctx = ToolCtx::new();
212        let args = ToolArgs {
213            positional: vec![Value::Str("HEAD~1..HEAD".into())],
214            named: vec![
215                (
216                    "cwd".into(),
217                    Value::Str(tmp.path().to_string_lossy().into()),
218                ),
219                (
220                    "paths".into(),
221                    Value::List(vec![Value::Str("a.txt".into())]),
222                ),
223            ],
224        };
225        let v = GitDiff.call(args, &ctx).await.unwrap();
226        let Value::Struct(fields) = v else {
227            panic!("struct");
228        };
229        let files = fields
230            .iter()
231            .find(|(k, _)| k == "files")
232            .and_then(|(_, v)| {
233                if let Value::List(xs) = v {
234                    Some(xs.clone())
235                } else {
236                    None
237                }
238            })
239            .unwrap();
240        let names: Vec<String> = files
241            .into_iter()
242            .filter_map(|v| if let Value::Str(s) = v { Some(s) } else { None })
243            .collect();
244        assert_eq!(names, vec!["a.txt".to_string()], "files={names:?}");
245    }
246
247    #[tokio::test]
248    async fn diff_outside_git_repo_errors() {
249        let tmp = tempfile::tempdir().unwrap();
250        let ctx = ToolCtx::new();
251        let args = ToolArgs {
252            positional: vec![Value::Str("HEAD".into())],
253            named: vec![(
254                "cwd".into(),
255                Value::Str(tmp.path().to_string_lossy().into()),
256            )],
257        };
258        let err = GitDiff.call(args, &ctx).await.unwrap_err();
259        let msg = format!("{err}");
260        assert!(
261            msg.contains("not a git repository"),
262            "want repo error: {msg}"
263        );
264    }
265
266    #[tokio::test]
267    async fn diff_invalid_range_errors() {
268        if !have_git() {
269            eprintln!("skip");
270            return;
271        }
272        let tmp = tempfile::tempdir().unwrap();
273        seed_two_commits(tmp.path());
274        let ctx = ToolCtx::new();
275        let args = ToolArgs {
276            positional: vec![Value::Str("nope_ref..other_nope".into())],
277            named: vec![(
278                "cwd".into(),
279                Value::Str(tmp.path().to_string_lossy().into()),
280            )],
281        };
282        let err = GitDiff.call(args, &ctx).await.unwrap_err();
283        let msg = format!("{err}");
284        assert!(
285            msg.contains("libgit2") || msg.contains("revspec"),
286            "err={msg}"
287        );
288    }
289}