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}