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                    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}