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