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 old_content: None,
137 new_content: None,
138 unified_diff: Some(out.body.clone()),
139 run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
140 });
141 }
142 Ok(Value::Struct(vec![
143 ("diff".into(), Value::Str(out.body)),
144 (
145 "files".into(),
146 Value::List(out.files.into_iter().map(Value::Str).collect()),
147 ),
148 ]))
149 })
150 }
151}
152
153fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
154 let value = match args.named(name) {
155 Some(v) => v,
156 None => args.positional(pos)?,
157 };
158 match value {
159 Value::Str(s) => Ok(s.clone()),
160 other => Err(RuntimeError::TypeMismatch {
161 expected: "string".into(),
162 actual: other.kind_name().into(),
163 }),
164 }
165}
166
167fn extract_string_list(
168 args: &ToolArgs,
169 name: &str,
170 pos: usize,
171) -> Result<Vec<String>, RuntimeError> {
172 let value = match args.named(name) {
173 Some(v) => v,
174 None => match args.positional(pos) {
175 Ok(v) => v,
176 Err(_) => return Ok(Vec::new()),
177 },
178 };
179 match value {
180 Value::List(items) => {
181 let mut out = Vec::with_capacity(items.len());
182 for it in items {
183 match it {
184 Value::Str(s) => out.push(s.clone()),
185 other => {
186 return Err(RuntimeError::TypeMismatch {
187 expected: "list of string".into(),
188 actual: other.kind_name().into(),
189 });
190 }
191 }
192 }
193 Ok(out)
194 }
195 Value::Unit => Ok(Vec::new()),
196 other => Err(RuntimeError::TypeMismatch {
197 expected: "list of string".into(),
198 actual: other.kind_name().into(),
199 }),
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::git::GitCli;
207 use std::path::Path;
208
209 fn have_git() -> bool {
210 GitCli::ensure_available().is_ok()
211 }
212
213 fn seed_two_commits(dir: &Path) {
214 let cli = GitCli::at(dir);
215 cli.init("main").unwrap();
216 for (k, v) in [
217 ("user.email", "t@atman.local"),
218 ("user.name", "atman test"),
219 ("commit.gpgsign", "false"),
220 ] {
221 cli.run(&["config", k, v]).unwrap();
222 }
223 std::fs::write(dir.join("a.txt"), "line one\n").unwrap();
224 std::fs::write(dir.join("b.txt"), "b\n").unwrap();
225 cli.add_all().unwrap();
226 cli.commit("initial").unwrap();
227 std::fs::write(dir.join("a.txt"), "line one\nline two\n").unwrap();
228 std::fs::write(dir.join("c.txt"), "new file\n").unwrap();
229 cli.add_all().unwrap();
230 cli.commit("second").unwrap();
231 }
232
233 #[tokio::test]
234 async fn diff_returns_body_and_files() {
235 if !have_git() {
236 eprintln!("skip: git not on PATH");
237 return;
238 }
239 let tmp = tempfile::tempdir().unwrap();
240 seed_two_commits(tmp.path());
241 let ctx = ToolCtx::new();
242 let args = ToolArgs {
243 positional: vec![Value::Str("HEAD~1..HEAD".into())],
244 named: vec![(
245 "cwd".into(),
246 Value::Str(tmp.path().to_string_lossy().into()),
247 )],
248 };
249 let v = GitDiff.call(args, &ctx).await.unwrap();
250 let Value::Struct(fields) = v else {
251 panic!("expected struct, got {v:?}");
252 };
253 let diff = fields
254 .iter()
255 .find(|(k, _)| k == "diff")
256 .and_then(|(_, v)| {
257 if let Value::Str(s) = v {
258 Some(s.clone())
259 } else {
260 None
261 }
262 })
263 .unwrap();
264 assert!(diff.contains("+line two"), "want addition, got:\n{diff}");
265 assert!(diff.contains("+new file"), "want new file:\n{diff}");
266 let files = fields
267 .iter()
268 .find(|(k, _)| k == "files")
269 .and_then(|(_, v)| {
270 if let Value::List(xs) = v {
271 Some(xs.clone())
272 } else {
273 None
274 }
275 })
276 .unwrap();
277 let names: Vec<String> = files
278 .into_iter()
279 .filter_map(|v| if let Value::Str(s) = v { Some(s) } else { None })
280 .collect();
281 assert!(names.contains(&"a.txt".to_string()), "files={names:?}");
282 assert!(names.contains(&"c.txt".to_string()), "files={names:?}");
283 }
284
285 #[tokio::test]
286 async fn diff_paths_filter_narrows() {
287 if !have_git() {
288 eprintln!("skip");
289 return;
290 }
291 let tmp = tempfile::tempdir().unwrap();
292 seed_two_commits(tmp.path());
293 let ctx = ToolCtx::new();
294 let args = ToolArgs {
295 positional: vec![Value::Str("HEAD~1..HEAD".into())],
296 named: vec![
297 (
298 "cwd".into(),
299 Value::Str(tmp.path().to_string_lossy().into()),
300 ),
301 (
302 "paths".into(),
303 Value::List(vec![Value::Str("a.txt".into())]),
304 ),
305 ],
306 };
307 let v = GitDiff.call(args, &ctx).await.unwrap();
308 let Value::Struct(fields) = v else {
309 panic!("struct");
310 };
311 let files = fields
312 .iter()
313 .find(|(k, _)| k == "files")
314 .and_then(|(_, v)| {
315 if let Value::List(xs) = v {
316 Some(xs.clone())
317 } else {
318 None
319 }
320 })
321 .unwrap();
322 let names: Vec<String> = files
323 .into_iter()
324 .filter_map(|v| if let Value::Str(s) = v { Some(s) } else { None })
325 .collect();
326 assert_eq!(names, vec!["a.txt".to_string()], "files={names:?}");
327 }
328
329 #[tokio::test]
330 async fn diff_outside_git_repo_errors() {
331 let tmp = tempfile::tempdir().unwrap();
332 let ctx = ToolCtx::new();
333 let args = ToolArgs {
334 positional: vec![Value::Str("HEAD".into())],
335 named: vec![(
336 "cwd".into(),
337 Value::Str(tmp.path().to_string_lossy().into()),
338 )],
339 };
340 let err = GitDiff.call(args, &ctx).await.unwrap_err();
341 let msg = format!("{err}");
342 assert!(
343 msg.contains("not a git repository"),
344 "want repo error: {msg}"
345 );
346 }
347
348 #[tokio::test]
349 async fn diff_invalid_range_errors() {
350 if !have_git() {
351 eprintln!("skip");
352 return;
353 }
354 let tmp = tempfile::tempdir().unwrap();
355 seed_two_commits(tmp.path());
356 let ctx = ToolCtx::new();
357 let args = ToolArgs {
358 positional: vec![Value::Str("nope_ref..other_nope".into())],
359 named: vec![(
360 "cwd".into(),
361 Value::Str(tmp.path().to_string_lossy().into()),
362 )],
363 };
364 let err = GitDiff.call(args, &ctx).await.unwrap_err();
365 let msg = format!("{err}");
366 assert!(
367 msg.contains("libgit2") || msg.contains("revspec"),
368 "err={msg}"
369 );
370 }
371}