Skip to main content

atman_runtime/tools/
git_ops.rs

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}