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}