1use std::path::PathBuf;
2use std::time::Duration;
3
4use git2::{BranchType, Commit, Diff, DiffFormat, Repository, Status, StatusOptions};
5
6use crate::error::RuntimeError;
7use crate::stream::StreamFrame;
8use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
9use crate::value::Value;
10
11pub struct GitStatus;
12
13pub struct GitShow;
14
15pub struct GitLog;
16
17impl Tool for GitLog {
18 fn name(&self) -> &str {
19 "git.log"
20 }
21
22 fn tier(&self) -> Tier {
23 Tier::Zero
24 }
25
26 fn description(&self) -> Option<&str> {
27 Some("List recent commits and preview the patch for the newest commit.")
28 }
29
30 fn input_schema(&self) -> serde_json::Value {
31 serde_json::json!({
32 "type": "object",
33 "properties": {
34 "limit": {"type": "integer", "default": 20, "minimum": 1, "maximum": 100, "description": "Maximum commits to return."},
35 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
36 }
37 })
38 }
39
40 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
41 Box::pin(async move {
42 let limit = extract_optional_int(&args, "limit")
43 .unwrap_or(20)
44 .clamp(1, 100) as usize;
45 let cwd = extract_cwd(&args, "git.log cwd")?;
46 let repo = Repository::open(&cwd)
47 .map_err(|e| RuntimeError::ToolFailed(format!("git.log: {e}")))?;
48 let mut revwalk = repo
49 .revwalk()
50 .map_err(|e| RuntimeError::ToolFailed(format!("git.log revwalk: {e}")))?;
51 revwalk
52 .push_head()
53 .map_err(|e| RuntimeError::ToolFailed(format!("git.log head: {e}")))?;
54
55 let mut commits = Vec::new();
56 let mut preview_diff = String::new();
57 let mut preview_files = Vec::new();
58 for oid in revwalk.take(limit) {
59 let oid = oid.map_err(|e| RuntimeError::ToolFailed(format!("git.log oid: {e}")))?;
60 let commit = repo
61 .find_commit(oid)
62 .map_err(|e| RuntimeError::ToolFailed(format!("git.log commit: {e}")))?;
63 let diff = commit_diff(&repo, &commit, "git.log")?;
64 let stats = diff
65 .stats()
66 .map_err(|e| RuntimeError::ToolFailed(format!("git.log stats: {e}")))?;
67 if commits.is_empty() {
68 preview_files = diff_files(&diff, "git.log")?;
69 preview_diff = diff_patch(&diff, "git.log")?;
70 }
71 commits.push(commit_entry(&commit, &stats));
72 }
73
74 if let Some(tx) = &ctx.stream_tx
75 && !preview_diff.is_empty()
76 {
77 let _ = tx.send(StreamFrame::DiffPreview {
78 title: "git log HEAD".into(),
79 old_content: None,
80 new_content: None,
81 unified_diff: Some(preview_diff.clone()),
82 });
83 }
84
85 Ok(Value::Struct(vec![
86 ("commits".into(), Value::List(commits)),
87 ("diff".into(), Value::Str(preview_diff)),
88 (
89 "files".into(),
90 Value::List(preview_files.into_iter().map(Value::Str).collect()),
91 ),
92 ]))
93 })
94 }
95}
96
97impl Tool for GitShow {
98 fn name(&self) -> &str {
99 "git.show"
100 }
101
102 fn tier(&self) -> Tier {
103 Tier::Zero
104 }
105
106 fn description(&self) -> Option<&str> {
107 Some("Show the patch introduced by one commit.")
108 }
109
110 fn input_schema(&self) -> serde_json::Value {
111 serde_json::json!({
112 "type": "object",
113 "properties": {
114 "sha": {"type": "string", "description": "Commit SHA or rev."},
115 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
116 },
117 "required": ["sha"]
118 })
119 }
120
121 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
122 Box::pin(async move {
123 let sha = extract_string(&args, "sha", 0)?;
124 let cwd = extract_cwd(&args, "git.show cwd")?;
125 let repo = Repository::open(&cwd)
126 .map_err(|e| RuntimeError::ToolFailed(format!("git.show: {e}")))?;
127 let object = repo
128 .revparse_single(&sha)
129 .map_err(|e| RuntimeError::ToolFailed(format!("git.show rev: {e}")))?;
130 let commit = object
131 .peel_to_commit()
132 .map_err(|e| RuntimeError::ToolFailed(format!("git.show commit: {e}")))?;
133 let diff = commit_diff(&repo, &commit, "git.show")?;
134 let files = diff_files(&diff, "git.show")?;
135 let body = diff_patch(&diff, "git.show")?;
136 let resolved = commit.id().to_string();
137 if let Some(tx) = &ctx.stream_tx {
138 let _ = tx.send(StreamFrame::DiffPreview {
139 title: format!("git show {sha}"),
140 old_content: None,
141 new_content: None,
142 unified_diff: Some(body.clone()),
143 });
144 }
145 Ok(Value::Struct(vec![
146 ("sha".into(), Value::Str(resolved)),
147 ("diff".into(), Value::Str(body)),
148 (
149 "files".into(),
150 Value::List(files.into_iter().map(Value::Str).collect()),
151 ),
152 ]))
153 })
154 }
155}
156
157impl Tool for GitStatus {
158 fn name(&self) -> &str {
159 "git.status"
160 }
161
162 fn tier(&self) -> Tier {
163 Tier::Zero
164 }
165
166 fn description(&self) -> Option<&str> {
167 Some("Show working tree status: staged, unstaged, and untracked files.")
168 }
169
170 fn input_schema(&self) -> serde_json::Value {
171 serde_json::json!({
172 "type": "object",
173 "properties": {
174 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
175 }
176 })
177 }
178
179 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
180 Box::pin(async move {
181 let cwd = extract_cwd(&args, "git.status cwd")?;
182 let repo = Repository::open(&cwd)
183 .map_err(|e| RuntimeError::ToolFailed(format!("git.status: {e}")))?;
184 let mut opts = StatusOptions::new();
185 opts.include_untracked(true)
186 .renames_head_to_index(true)
187 .renames_index_to_workdir(true);
188 let statuses = repo
189 .statuses(Some(&mut opts))
190 .map_err(|e| RuntimeError::ToolFailed(format!("git.status: {e}")))?;
191 let mut staged = Vec::new();
192 let mut unstaged = Vec::new();
193 let mut untracked = Vec::new();
194 for entry in statuses.iter() {
195 let status = entry.status();
196 let Some(path) = entry.path().map(str::to_string) else {
197 continue;
198 };
199 if status.is_wt_new() {
200 untracked.push(Value::Str(path.clone()));
201 }
202 if let Some(label) = index_status(status) {
203 staged.push(status_entry(path.clone(), label));
204 }
205 if let Some(label) = worktree_status(status) {
206 unstaged.push(status_entry(path, label));
207 }
208 }
209 Ok(Value::Struct(vec![
210 ("staged".into(), Value::List(staged)),
211 ("unstaged".into(), Value::List(unstaged)),
212 ("untracked".into(), Value::List(untracked)),
213 ]))
214 })
215 }
216}
217
218pub struct GitAdd;
219
220impl Tool for GitAdd {
221 fn name(&self) -> &str {
222 "git.add"
223 }
224
225 fn tier(&self) -> Tier {
226 Tier::Two
227 }
228
229 fn description(&self) -> Option<&str> {
230 Some("Stage files for commit. Pass specific paths — do NOT stage everything blindly.")
231 }
232
233 fn input_schema(&self) -> serde_json::Value {
234 serde_json::json!({
235 "type": "object",
236 "properties": {
237 "paths": {"type": "array", "items": {"type": "string"}, "description": "File paths to stage."},
238 "cwd": {"type": "string", "description": "Optional working dir."}
239 },
240 "required": ["paths"]
241 })
242 }
243
244 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
245 Box::pin(async move {
246 let paths = extract_string_list(&args, "paths")?;
247 let cwd = extract_cwd(&args, "git.add cwd")?;
248 let repo = Repository::open(&cwd)
249 .map_err(|e| RuntimeError::ToolFailed(format!("git.add: {e}")))?;
250 let mut index = repo
251 .index()
252 .map_err(|e| RuntimeError::ToolFailed(format!("git.add index: {e}")))?;
253 for p in &paths {
254 index
255 .add_path(std::path::Path::new(p))
256 .map_err(|e| RuntimeError::ToolFailed(format!("git.add {p}: {e}")))?;
257 }
258 index
259 .write()
260 .map_err(|e| RuntimeError::ToolFailed(format!("git.add write: {e}")))?;
261 Ok(Value::Struct(vec![(
262 "staged".into(),
263 Value::List(paths.into_iter().map(Value::Str).collect()),
264 )]))
265 })
266 }
267}
268
269pub struct GitCommit;
270
271impl Tool for GitCommit {
272 fn name(&self) -> &str {
273 "git.commit"
274 }
275
276 fn tier(&self) -> Tier {
277 Tier::Two
278 }
279
280 fn description(&self) -> Option<&str> {
281 Some("Commit staged changes. Use 'amend: true' to amend the last commit.")
282 }
283
284 fn input_schema(&self) -> serde_json::Value {
285 serde_json::json!({
286 "type": "object",
287 "properties": {
288 "message": {"type": "string", "description": "Commit message."},
289 "amend": {"type": "boolean", "default": false, "description": "Amend the last commit instead of creating a new commit."},
290 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
291 },
292 "required": ["message"]
293 })
294 }
295
296 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
297 Box::pin(async move {
298 let message = extract_string(&args, "message", 0)?;
299 let amend = extract_optional_bool(&args, "amend").unwrap_or(false);
300 let cwd = extract_cwd(&args, "git.commit cwd")?;
301 let repo = Repository::open(&cwd)
302 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit: {e}")))?;
303 let files_count = staged_count(&repo, "git.commit")?;
304 let mut index = repo
305 .index()
306 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit: {e}")))?;
307 let tree_id = index
308 .write_tree()
309 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit: {e}")))?;
310 let tree = repo
311 .find_tree(tree_id)
312 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit: {e}")))?;
313 let sig = repo
314 .signature()
315 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit signature: {e}")))?;
316 let head = repo
317 .head()
318 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit head: {e}")))?;
319 let parent = head
320 .peel_to_commit()
321 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit head: {e}")))?;
322 let oid = if amend {
323 parent
324 .amend(
325 Some("HEAD"),
326 Some(&sig),
327 Some(&sig),
328 None,
329 Some(&message),
330 Some(&tree),
331 )
332 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit amend: {e}")))?
333 } else {
334 repo.commit(Some("HEAD"), &sig, &sig, &message, &tree, &[&parent])
335 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit: {e}")))?
336 };
337 index
338 .write()
339 .map_err(|e| RuntimeError::ToolFailed(format!("git.commit index: {e}")))?;
340 Ok(Value::Struct(vec![
341 ("sha".into(), Value::Str(oid.to_string())),
342 ("message".into(), Value::Str(message)),
343 ("files_count".into(), Value::Int(files_count)),
344 ]))
345 })
346 }
347}
348
349pub struct GitBranch;
350
351impl Tool for GitBranch {
352 fn name(&self) -> &str {
353 "git.branch"
354 }
355
356 fn tier(&self) -> Tier {
357 Tier::Two
358 }
359
360 fn description(&self) -> Option<&str> {
361 Some("Create and/or checkout a git branch.")
362 }
363
364 fn input_schema(&self) -> serde_json::Value {
365 serde_json::json!({
366 "type": "object",
367 "properties": {
368 "name": {"type": "string", "description": "Branch name."},
369 "create": {"type": "boolean", "default": true, "description": "Create the branch before checkout."},
370 "checkout": {"type": "boolean", "default": true, "description": "Checkout the branch."},
371 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
372 },
373 "required": ["name"]
374 })
375 }
376
377 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
378 Box::pin(async move {
379 let name = extract_string(&args, "name", 0)?;
380 let create = extract_optional_bool(&args, "create").unwrap_or(true);
381 let checkout = extract_optional_bool(&args, "checkout").unwrap_or(true);
382 let cwd = extract_cwd(&args, "git.branch cwd")?;
383 let repo = Repository::open(&cwd)
384 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch: {e}")))?;
385 if create {
386 let head = repo
387 .head()
388 .and_then(|h| h.peel_to_commit())
389 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch head: {e}")))?;
390 repo.branch(&name, &head, false)
391 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch: {e}")))?;
392 } else {
393 repo.find_branch(&name, BranchType::Local)
394 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch: {e}")))?;
395 }
396 if checkout {
397 repo.set_head(&format!("refs/heads/{name}"))
398 .map_err(|e| RuntimeError::ToolFailed(format!("git.branch checkout: {e}")))?;
399 }
400 Ok(Value::Struct(vec![
401 ("branch".into(), Value::Str(name)),
402 ("created".into(), Value::Bool(create)),
403 ("checked_out".into(), Value::Bool(checkout)),
404 ]))
405 })
406 }
407}
408
409pub struct GitPush;
410
411impl Tool for GitPush {
412 fn name(&self) -> &str {
413 "git.push"
414 }
415
416 fn tier(&self) -> Tier {
417 Tier::Three
418 }
419
420 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
421 ApprovalLevel::Dangerous
422 }
423
424 fn description(&self) -> Option<&str> {
425 Some("Push current branch to remote. Requires approval.")
426 }
427
428 fn input_schema(&self) -> serde_json::Value {
429 serde_json::json!({
430 "type": "object",
431 "properties": {
432 "remote": {"type": "string", "default": "origin", "description": "Remote name."},
433 "branch": {"type": "string", "description": "Branch name; defaults to current branch."},
434 "cwd": {"type": "string", "description": "Optional working dir; defaults to current process directory."}
435 }
436 })
437 }
438
439 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
440 Box::pin(async move {
441 let remote =
442 extract_optional_string(&args, "remote").unwrap_or_else(|| "origin".into());
443 let cwd = extract_cwd(&args, "git.push cwd")?;
444 let branch = match extract_optional_string(&args, "branch") {
445 Some(branch) => branch,
446 None => current_branch(&cwd)?,
447 };
448 let mut child = tokio::process::Command::new("git");
449 child.args(["push", &remote, &branch]).current_dir(&cwd);
450 let output = tokio::time::timeout(Duration::from_secs(300), child.output())
451 .await
452 .map_err(|_| RuntimeError::ToolFailed("git.push timeout after 300s".into()))?
453 .map_err(|e| RuntimeError::ToolFailed(format!("git.push spawn: {e}")))?;
454 let stdout = String::from_utf8_lossy(&output.stdout);
455 let stderr = String::from_utf8_lossy(&output.stderr);
456 let combined = match (stdout.is_empty(), stderr.is_empty()) {
457 (true, true) => String::new(),
458 (false, true) => stdout.into_owned(),
459 (true, false) => stderr.into_owned(),
460 (false, false) => format!("{stdout}\n{stderr}"),
461 };
462 Ok(Value::Struct(vec![
463 ("ok".into(), Value::Bool(output.status.success())),
464 ("remote".into(), Value::Str(remote)),
465 ("branch".into(), Value::Str(branch)),
466 ("output".into(), Value::Str(combined)),
467 ]))
468 })
469 }
470}
471
472fn extract_cwd(args: &ToolArgs, label: &str) -> Result<PathBuf, RuntimeError> {
473 match args.named("cwd") {
474 Some(Value::Path(p)) => Ok(p.clone()),
475 Some(Value::Str(s)) => Ok(PathBuf::from(s)),
476 Some(other) => Err(RuntimeError::TypeMismatch {
477 expected: "string".into(),
478 actual: other.kind_name().into(),
479 }),
480 None => {
481 std::env::current_dir().map_err(|e| RuntimeError::ToolFailed(format!("{label}: {e}")))
482 }
483 }
484}
485
486fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
487 let value = match args.named(name) {
488 Some(v) => v,
489 None => args.positional(pos)?,
490 };
491 match value {
492 Value::Str(s) => Ok(s.clone()),
493 other => Err(RuntimeError::TypeMismatch {
494 expected: "string".into(),
495 actual: other.kind_name().into(),
496 }),
497 }
498}
499
500fn extract_string_list(args: &ToolArgs, name: &str) -> Result<Vec<String>, RuntimeError> {
501 match args.named(name) {
502 Some(Value::List(items)) => items
503 .iter()
504 .map(|v| match v {
505 Value::Str(s) => Ok(s.clone()),
506 other => Err(RuntimeError::TypeMismatch {
507 expected: "string".into(),
508 actual: other.kind_name().into(),
509 }),
510 })
511 .collect(),
512 Some(other) => Err(RuntimeError::TypeMismatch {
513 expected: "list<string>".into(),
514 actual: other.kind_name().into(),
515 }),
516 None => Err(RuntimeError::MissingArg(name.into())),
517 }
518}
519
520fn extract_optional_string(args: &ToolArgs, name: &str) -> Option<String> {
521 match args.named(name)? {
522 Value::Str(s) => Some(s.clone()),
523 _ => None,
524 }
525}
526
527fn extract_optional_bool(args: &ToolArgs, name: &str) -> Option<bool> {
528 match args.named(name)? {
529 Value::Bool(b) => Some(*b),
530 _ => None,
531 }
532}
533
534fn extract_optional_int(args: &ToolArgs, name: &str) -> Option<i64> {
535 match args.named(name)? {
536 Value::Int(n) => Some(*n),
537 _ => None,
538 }
539}
540
541fn commit_diff<'repo>(
542 repo: &'repo Repository,
543 commit: &Commit<'repo>,
544 tool: &str,
545) -> Result<Diff<'repo>, RuntimeError> {
546 let new_tree = commit
547 .tree()
548 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} tree: {e}")))?;
549 let old_tree = if commit.parent_count() == 0 {
550 None
551 } else {
552 Some(
553 commit
554 .parent(0)
555 .and_then(|p| p.tree())
556 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} parent: {e}")))?,
557 )
558 };
559 repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)
560 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} diff: {e}")))
561}
562
563fn diff_files(diff: &Diff<'_>, tool: &str) -> Result<Vec<String>, RuntimeError> {
564 let mut files = Vec::new();
565 diff.foreach(
566 &mut |delta, _| {
567 let path = delta
568 .new_file()
569 .path()
570 .or_else(|| delta.old_file().path())
571 .map(|p| p.to_string_lossy().into_owned());
572 if let Some(path) = path
573 && !files.contains(&path)
574 {
575 files.push(path);
576 }
577 true
578 },
579 None,
580 None,
581 None,
582 )
583 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} files: {e}")))?;
584 Ok(files)
585}
586
587fn diff_patch(diff: &Diff<'_>, tool: &str) -> Result<String, RuntimeError> {
588 let mut body = String::new();
589 diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
590 match line.origin() {
591 'F' | 'H' => body.push_str(&String::from_utf8_lossy(line.content())),
592 '+' | '-' | ' ' => {
593 body.push(line.origin());
594 body.push_str(&String::from_utf8_lossy(line.content()));
595 }
596 _ => body.push_str(&String::from_utf8_lossy(line.content())),
597 }
598 true
599 })
600 .map_err(|e| RuntimeError::ToolFailed(format!("{tool} patch: {e}")))?;
601 Ok(body)
602}
603
604fn commit_entry(commit: &Commit<'_>, stats: &git2::DiffStats) -> Value {
605 let author = commit.author();
606 let author_name = author.name().unwrap_or_default();
607 let author_email = author.email().unwrap_or_default();
608 let author_display = if author_email.is_empty() {
609 author_name.to_string()
610 } else if author_name.is_empty() {
611 author_email.to_string()
612 } else {
613 format!("{author_name} <{author_email}>")
614 };
615 Value::Struct(vec![
616 ("sha".into(), Value::Str(commit.id().to_string())),
617 ("author".into(), Value::Str(author_display)),
618 (
619 "date".into(),
620 Value::Str(commit.time().seconds().to_string()),
621 ),
622 (
623 "message".into(),
624 Value::Str(commit.summary().unwrap_or_default().to_string()),
625 ),
626 (
627 "stats".into(),
628 Value::Struct(vec![
629 ("files".into(), Value::Int(stats.files_changed() as i64)),
630 ("insertions".into(), Value::Int(stats.insertions() as i64)),
631 ("deletions".into(), Value::Int(stats.deletions() as i64)),
632 ]),
633 ),
634 ])
635}
636
637fn index_status(status: Status) -> Option<&'static str> {
638 if status.is_index_new() {
639 Some("new")
640 } else if status.is_index_modified() {
641 Some("modified")
642 } else if status.is_index_deleted() {
643 Some("deleted")
644 } else if status.is_index_renamed() {
645 Some("renamed")
646 } else {
647 None
648 }
649}
650
651fn worktree_status(status: Status) -> Option<&'static str> {
652 if status.is_wt_modified() {
653 Some("modified")
654 } else if status.is_wt_deleted() {
655 Some("deleted")
656 } else if status.is_wt_renamed() {
657 Some("renamed")
658 } else {
659 None
660 }
661}
662
663fn status_entry(path: String, status: &str) -> Value {
664 Value::Struct(vec![
665 ("path".into(), Value::Str(path)),
666 ("status".into(), Value::Str(status.into())),
667 ])
668}
669
670fn staged_count(repo: &Repository, tool: &str) -> Result<i64, RuntimeError> {
671 let mut opts = StatusOptions::new();
672 opts.include_untracked(false).renames_head_to_index(true);
673 let statuses = repo
674 .statuses(Some(&mut opts))
675 .map_err(|e| RuntimeError::ToolFailed(format!("{tool}: {e}")))?;
676 Ok(statuses
677 .iter()
678 .filter(|entry| index_status(entry.status()).is_some())
679 .count() as i64)
680}
681
682fn current_branch(cwd: &std::path::Path) -> Result<String, RuntimeError> {
683 let repo =
684 Repository::open(cwd).map_err(|e| RuntimeError::ToolFailed(format!("git.push: {e}")))?;
685 let head = repo
686 .head()
687 .map_err(|e| RuntimeError::ToolFailed(format!("git.push head: {e}")))?;
688 head.shorthand()
689 .map(str::to_string)
690 .ok_or_else(|| RuntimeError::ToolFailed("git.push: detached HEAD has no branch".into()))
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use crate::git::GitCli;
697 use std::path::Path;
698
699 fn have_git() -> bool {
700 GitCli::ensure_available().is_ok()
701 }
702
703 fn seed_two_commits(dir: &Path) {
704 let cli = GitCli::at(dir);
705 cli.init("main").unwrap();
706 for (k, v) in [
707 ("user.email", "t@atman.local"),
708 ("user.name", "atman test"),
709 ("commit.gpgsign", "false"),
710 ] {
711 cli.run(&["config", k, v]).unwrap();
712 }
713 std::fs::write(dir.join("a.txt"), "one\n").unwrap();
714 cli.add_all().unwrap();
715 cli.commit("initial").unwrap();
716 std::fs::write(dir.join("a.txt"), "one\ntwo\n").unwrap();
717 cli.add_all().unwrap();
718 cli.commit("second").unwrap();
719 }
720
721 #[tokio::test]
722 async fn log_returns_limited_commits_and_head_patch() {
723 if !have_git() {
724 eprintln!("skip: git not on PATH");
725 return;
726 }
727 let tmp = tempfile::tempdir().unwrap();
728 seed_two_commits(tmp.path());
729 let ctx = ToolCtx::new();
730 let args = ToolArgs {
731 positional: Vec::new(),
732 named: vec![
733 ("limit".into(), Value::Int(1)),
734 (
735 "cwd".into(),
736 Value::Str(tmp.path().to_string_lossy().into()),
737 ),
738 ],
739 };
740
741 let value = GitLog.call(args, &ctx).await.unwrap();
742 let commits = value.field("commits").unwrap();
743 let Value::List(commits) = commits else {
744 panic!("expected commits list: {commits:?}");
745 };
746 assert_eq!(commits.len(), 1);
747 let head = &commits[0];
748 assert!(matches!(head.field("message"), Some(Value::Str(s)) if s == "second"));
749 assert!(matches!(head.field("sha"), Some(Value::Str(sha)) if sha.len() == 40));
750 let stats = head.field("stats").unwrap();
751 assert!(matches!(stats.field("files"), Some(Value::Int(1))));
752 assert!(matches!(stats.field("insertions"), Some(Value::Int(1))));
753 let diff = value.field("diff").unwrap();
754 assert!(
755 matches!(diff, Value::Str(s) if s.contains("+two")),
756 "diff={diff:?}"
757 );
758 }
759}