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}