Skip to main content

imp_core/tools/
git.rs

1use std::path::{Path, PathBuf};
2use std::process::Stdio;
3
4use async_trait::async_trait;
5use serde_json::json;
6use tokio::process::Command;
7
8use super::{resolve_path, truncate_head, Tool, ToolContext, ToolOutput};
9use crate::config::AgentMode;
10use crate::error::Result;
11
12const DEFAULT_LOG_LIMIT: u32 = 10;
13const DISPLAY_MAX_LINES: usize = 400;
14const DISPLAY_MAX_BYTES: usize = 32 * 1024;
15
16pub struct GitTool;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum GitActionClass {
20    ReadOnly,
21    Mutating,
22}
23
24#[async_trait]
25impl Tool for GitTool {
26    fn name(&self) -> &str {
27        "git"
28    }
29
30    fn label(&self) -> &str {
31        "Git"
32    }
33
34    fn description(&self) -> &str {
35        "Local git status, diff, log, stage, commit, restore."
36    }
37
38    fn parameters(&self) -> serde_json::Value {
39        json!({
40            "type": "object",
41            "properties": {
42                "action": {
43                    "type": "string",
44                    "enum": [
45                        "status",
46                        "diff",
47                        "log",
48                        "merge_base",
49                        "stage",
50                        "commit",
51                        "restore"
52                    ],
53                    "description": "Git action"
54                },
55                "path": {
56                    "type": "string",
57                    "description": "Repo/worktree path"
58                },
59                "files": {
60                    "type": "array",
61                    "items": { "type": "string" },
62                    "description": "File paths"
63                },
64                "all_changes": {
65                    "type": "boolean",
66                    "description": "Stage all changes"
67                },
68                "cached": {
69                    "type": "boolean",
70                    "description": "Diff staged changes"
71                },
72                "base": {
73                    "type": "string",
74                    "description": "Diff base ref"
75                },
76                "head": {
77                    "type": "string",
78                    "description": "Diff head ref"
79                },
80                "ref1": {
81                    "type": "string",
82                    "description": "First ref"
83                },
84                "ref2": {
85                    "type": "string",
86                    "description": "Second ref"
87                },
88                "limit": {
89                    "type": "integer",
90                    "minimum": 1,
91                    "maximum": 100,
92                    "description": "Log limit"
93                },
94                "message": {
95                    "type": "string",
96                    "description": "Commit message"
97                },
98                "allow_empty": {
99                    "type": "boolean",
100                    "description": "Allow empty commit"
101                },
102                "preserve_index": {
103                    "type": "boolean",
104                    "description": "For file-targeted commits, preserve the existing index by committing via a temporary index (default true)"
105                },
106                "source": {
107                    "type": "string",
108                    "description": "Restore source ref"
109                }
110            },
111            "required": ["action"]
112        })
113    }
114
115    fn is_readonly(&self) -> bool {
116        false
117    }
118
119    async fn execute(
120        &self,
121        _call_id: &str,
122        params: serde_json::Value,
123        ctx: ToolContext,
124    ) -> Result<ToolOutput> {
125        let action = match params["action"].as_str() {
126            Some(action) => action,
127            None => return Ok(ToolOutput::error("Missing required parameter: action")),
128        };
129
130        let Some(class) = action_class(action) else {
131            return Ok(ToolOutput::error(format!(
132                "Unknown git action \"{action}\""
133            )));
134        };
135
136        if matches!(class, GitActionClass::Mutating)
137            && !matches!(ctx.mode, AgentMode::Full | AgentMode::Worker)
138        {
139            return Ok(ToolOutput::error(format!(
140                "git action `{action}` is not permitted in {:?} mode; mutating git actions are limited to full/worker execution",
141                ctx.mode
142            )));
143        }
144
145        let cwd = match resolve_git_cwd(&ctx.cwd, params.get("path").and_then(|v| v.as_str())) {
146            Ok(path) => path,
147            Err(err) => return Ok(ToolOutput::error(err)),
148        };
149
150        let repo_root = match repo_root(&cwd).await {
151            Ok(path) => path,
152            Err(err) => return Ok(ToolOutput::error(err)),
153        };
154
155        match action {
156            "status" => status_action(&cwd, &repo_root).await,
157            "diff" => diff_action(&cwd, &repo_root, &params).await,
158            "log" => log_action(&cwd, &repo_root, &params).await,
159            "merge_base" => merge_base_action(&cwd, &repo_root, &params).await,
160            "worktree_info" => Ok(ToolOutput::error(
161                "git worktree actions moved to the worktree tool; use action=list",
162            )),
163            "stage" => stage_action(&cwd, &repo_root, &params).await,
164            "commit" => commit_action(&cwd, &repo_root, &params).await,
165            "restore" => restore_action(&cwd, &repo_root, &params, &ctx).await,
166            "worktree_add" => Ok(ToolOutput::error(
167                "git worktree actions moved to the worktree tool; use action=add",
168            )),
169            "worktree_remove" => Ok(ToolOutput::error(
170                "git worktree actions moved to the worktree tool; use action=remove",
171            )),
172            _ => Ok(ToolOutput::error(format!(
173                "Unsupported git action `{action}`"
174            ))),
175        }
176    }
177}
178
179fn action_class(action: &str) -> Option<GitActionClass> {
180    match action {
181        "status" | "diff" | "log" | "merge_base" | "worktree_info" => {
182            Some(GitActionClass::ReadOnly)
183        }
184        "stage" | "commit" | "restore" | "worktree_add" | "worktree_remove" => {
185            Some(GitActionClass::Mutating)
186        }
187        _ => None,
188    }
189}
190
191fn resolve_git_cwd(session_cwd: &Path, raw: Option<&str>) -> std::result::Result<PathBuf, String> {
192    let path = match raw {
193        Some(raw) if !raw.trim().is_empty() => resolve_path(session_cwd, raw),
194        _ => session_cwd.to_path_buf(),
195    };
196
197    if path.is_dir() {
198        return Ok(path);
199    }
200
201    if path.is_file() {
202        return path.parent().map(Path::to_path_buf).ok_or_else(|| {
203            format!(
204                "Could not determine a working directory from file path: {}",
205                path.display()
206            )
207        });
208    }
209
210    Err(format!(
211        "git path not found or not accessible: {}",
212        path.display()
213    ))
214}
215
216async fn repo_root(cwd: &Path) -> std::result::Result<PathBuf, String> {
217    let output = run_git(cwd, ["rev-parse", "--show-toplevel"])
218        .await
219        .map_err(|err| format!("Failed to run git in {}: {err}", cwd.display()))?;
220    if !output.status.success() {
221        return Err(not_git_repo_message(cwd, &output));
222    }
223
224    let root = stdout_trimmed(&output);
225    if root.is_empty() {
226        return Err(format!(
227            "Failed to determine git repo root from {}",
228            cwd.display()
229        ));
230    }
231
232    Ok(PathBuf::from(root))
233}
234
235async fn status_action(cwd: &Path, repo_root: &Path) -> Result<ToolOutput> {
236    let output = run_git(cwd, ["status", "--porcelain=v1", "--branch"]).await?;
237    if !output.status.success() {
238        return Ok(git_failure("git status failed", &output));
239    }
240
241    let status_text = stdout_lossy(&output);
242    let mut branch_summary = String::new();
243    let mut entries = Vec::new();
244    let mut staged = 0u32;
245    let mut unstaged = 0u32;
246    let mut untracked = 0u32;
247
248    for line in status_text.lines() {
249        if let Some(rest) = line.strip_prefix("## ") {
250            branch_summary = rest.trim().to_string();
251            continue;
252        }
253        if line.len() < 3 {
254            continue;
255        }
256        let index_status = line.chars().next().unwrap_or(' ');
257        let worktree_status = line.chars().nth(1).unwrap_or(' ');
258        let path = line[3..].trim().to_string();
259        if index_status != ' ' && index_status != '?' {
260            staged += 1;
261        }
262        if worktree_status != ' ' && worktree_status != '?' {
263            unstaged += 1;
264        }
265        if index_status == '?' && worktree_status == '?' {
266            untracked += 1;
267        }
268        entries.push(json!({
269            "index_status": index_status.to_string(),
270            "worktree_status": worktree_status.to_string(),
271            "path": path,
272            "raw": line,
273        }));
274    }
275
276    let head = head_sha_short(cwd)
277        .await
278        .unwrap_or_else(|| "unknown".to_string());
279    let secondary = mana_core::worktree::detect_worktree(cwd).ok().flatten();
280    let clean = entries.is_empty();
281
282    let mut text = String::new();
283    text.push_str(&format!("repo: {}\n", repo_root.display()));
284    text.push_str(&format!(
285        "branch: {}\n",
286        display_or_unknown(&branch_summary)
287    ));
288    text.push_str(&format!("head: {head}\n"));
289    text.push_str(&format!(
290        "state: {}\n",
291        if clean { "clean" } else { "dirty" }
292    ));
293    if let Some(info) = &secondary {
294        text.push_str(&format!("worktree: secondary ({})\n", info.branch));
295        text.push_str(&format!("main worktree: {}\n", info.main_path.display()));
296    } else {
297        text.push_str("worktree: main\n");
298    }
299    if !entries.is_empty() {
300        text.push_str("changes:\n");
301        for entry in &entries {
302            if let Some(raw) = entry.get("raw").and_then(|v| v.as_str()) {
303                text.push_str(raw);
304                text.push('\n');
305            }
306        }
307    }
308
309    Ok(ToolOutput {
310        content: vec![imp_llm::ContentBlock::Text { text }],
311        details: json!({
312            "action": "status",
313            "repo_root": repo_root.display().to_string(),
314            "branch": branch_summary,
315            "head": head,
316            "clean": clean,
317            "counts": {
318                "staged": staged,
319                "unstaged": unstaged,
320                "untracked": untracked,
321            },
322            "entries": entries,
323            "secondary_worktree": secondary.as_ref().map(|info| json!({
324                "main_path": info.main_path.display().to_string(),
325                "worktree_path": info.worktree_path.display().to_string(),
326                "branch": info.branch,
327            })),
328        }),
329        is_error: false,
330    })
331}
332
333fn non_empty_param<'a>(params: &'a serde_json::Value, field_name: &str) -> Option<&'a str> {
334    params
335        .get(field_name)?
336        .as_str()
337        .map(str::trim)
338        .filter(|s| !s.is_empty())
339}
340
341fn validate_ref(value: &str, field_name: &str) -> std::result::Result<(), crate::error::Error> {
342    if value.starts_with('-') || value.chars().any(|c| c == '\0' || c.is_control()) {
343        return Err(crate::error::Error::Tool(format!(
344            "{field_name} must be a safe git ref"
345        )));
346    }
347    Ok(())
348}
349
350async fn diff_action(
351    cwd: &Path,
352    repo_root: &Path,
353    params: &serde_json::Value,
354) -> Result<ToolOutput> {
355    let files = parse_string_array(params, "files")?;
356    let cached = params["cached"].as_bool().unwrap_or(false);
357    let base = non_empty_param(params, "base");
358    let head = non_empty_param(params, "head");
359
360    let mut args = vec!["diff".to_string()];
361    if let Some(base) = base {
362        validate_ref(base, "base")?;
363        if let Some(head) = head {
364            validate_ref(head, "head")?;
365        }
366        let range = match head {
367            Some(head) => format!("{base}..{head}"),
368            None => format!("{base}..HEAD"),
369        };
370        args.push(range);
371    } else if cached {
372        args.push("--cached".to_string());
373    }
374
375    if !files.is_empty() {
376        args.push("--".to_string());
377        args.extend(files.iter().cloned());
378    }
379
380    let output = run_git_owned(cwd, args).await?;
381    if !output.status.success() {
382        return Ok(git_failure("git diff failed", &output));
383    }
384
385    let diff = stdout_lossy(&output);
386    let (display_content, display_note, temp_file) = truncate_for_display(&diff);
387    let text = if diff.trim().is_empty() {
388        "No diff.".to_string()
389    } else if display_note.is_empty() {
390        display_content.clone()
391    } else {
392        format!("{display_content}\n{display_note}")
393    };
394
395    Ok(ToolOutput {
396        content: vec![imp_llm::ContentBlock::Text { text }],
397        details: json!({
398            "action": "diff",
399            "repo_root": repo_root.display().to_string(),
400            "cached": cached,
401            "base": base,
402            "head": head,
403            "files": files,
404            "display_content": display_content,
405            "display_note": display_note,
406            "temp_file": temp_file.map(|p| p.display().to_string()),
407        }),
408        is_error: false,
409    })
410}
411
412async fn log_action(
413    cwd: &Path,
414    repo_root: &Path,
415    params: &serde_json::Value,
416) -> Result<ToolOutput> {
417    let files = parse_string_array(params, "files")?;
418    let limit = params["limit"]
419        .as_u64()
420        .unwrap_or(DEFAULT_LOG_LIMIT as u64)
421        .clamp(1, 100);
422
423    let mut args = vec![
424        "log".to_string(),
425        "--oneline".to_string(),
426        "--decorate".to_string(),
427        "-n".to_string(),
428        limit.to_string(),
429    ];
430    if !files.is_empty() {
431        args.push("--".to_string());
432        args.extend(files.iter().cloned());
433    }
434
435    let output = run_git_owned(cwd, args).await?;
436    if !output.status.success() {
437        return Ok(git_failure("git log failed", &output));
438    }
439
440    let log = stdout_lossy(&output);
441    let text = if log.trim().is_empty() {
442        "No commits matched.".to_string()
443    } else {
444        log.trim_end().to_string()
445    };
446
447    Ok(ToolOutput {
448        content: vec![imp_llm::ContentBlock::Text { text }],
449        details: json!({
450            "action": "log",
451            "repo_root": repo_root.display().to_string(),
452            "limit": limit,
453            "files": files,
454        }),
455        is_error: false,
456    })
457}
458
459async fn merge_base_action(
460    cwd: &Path,
461    repo_root: &Path,
462    params: &serde_json::Value,
463) -> Result<ToolOutput> {
464    let Some(ref1) = non_empty_param(params, "ref1") else {
465        return Ok(ToolOutput::error("Missing required parameter: ref1"));
466    };
467    validate_ref(ref1, "ref1")?;
468    let Some(ref2) = non_empty_param(params, "ref2") else {
469        return Ok(ToolOutput::error("Missing required parameter: ref2"));
470    };
471    validate_ref(ref2, "ref2")?;
472
473    let output = run_git_owned(
474        cwd,
475        vec!["merge-base".to_string(), ref1.to_string(), ref2.to_string()],
476    )
477    .await?;
478
479    if !output.status.success() {
480        return Ok(git_failure("git merge-base failed", &output));
481    }
482
483    let merge_base = stdout_trimmed(&output);
484    Ok(ToolOutput {
485        content: vec![imp_llm::ContentBlock::Text {
486            text: merge_base.clone(),
487        }],
488        details: json!({
489            "action": "merge_base",
490            "repo_root": repo_root.display().to_string(),
491            "ref1": ref1,
492            "ref2": ref2,
493            "merge_base": merge_base,
494        }),
495        is_error: false,
496    })
497}
498
499async fn stage_action(
500    cwd: &Path,
501    repo_root: &Path,
502    params: &serde_json::Value,
503) -> Result<ToolOutput> {
504    let files = parse_string_array(params, "files")?;
505    let all = params
506        .get("all_changes")
507        .or_else(|| params.get("all"))
508        .and_then(|value| value.as_bool())
509        .unwrap_or(false);
510
511    let args = if all {
512        vec!["add".to_string(), "-A".to_string()]
513    } else {
514        if files.is_empty() {
515            return Ok(ToolOutput::error(
516                "stage requires either files[] or all=true",
517            ));
518        }
519        let mut args = vec!["add".to_string(), "--".to_string()];
520        args.extend(files.iter().cloned());
521        args
522    };
523
524    let output = run_git_owned(cwd, args).await?;
525    if !output.status.success() {
526        return Ok(git_failure("git add failed", &output));
527    }
528
529    let summary = if all {
530        "Staged all changes".to_string()
531    } else {
532        format!("Staged {} path(s)", files.len())
533    };
534
535    Ok(ToolOutput {
536        content: vec![imp_llm::ContentBlock::Text {
537            text: summary.clone(),
538        }],
539        details: json!({
540            "action": "stage",
541            "repo_root": repo_root.display().to_string(),
542            "all_changes": all,
543            "files": files,
544            "recovery": {
545                "undo": if all { "git reset" } else { "git reset -- <files>" },
546                "files": files,
547                "all_changes": all,
548            },
549            "summary": summary,
550        }),
551        is_error: false,
552    })
553}
554
555async fn commit_action(
556    cwd: &Path,
557    repo_root: &Path,
558    params: &serde_json::Value,
559) -> Result<ToolOutput> {
560    let Some(message) = params["message"].as_str() else {
561        return Ok(ToolOutput::error("Missing required parameter: message"));
562    };
563    if message.trim().is_empty() {
564        return Ok(ToolOutput::error("Commit message cannot be empty"));
565    }
566
567    let allow_empty = params
568        .get("allow_empty")
569        .or_else(|| params.get("allowEmpty"))
570        .and_then(|value| value.as_bool())
571        .unwrap_or(false);
572    let files = parse_string_array(params, "files")?;
573    let preserve_index = params
574        .get("preserve_index")
575        .and_then(|value| value.as_bool())
576        .unwrap_or(true);
577
578    if !files.is_empty() && preserve_index {
579        return targeted_commit_action(cwd, repo_root, message, allow_empty, &files).await;
580    }
581
582    let mut args = vec!["commit".to_string(), "-m".to_string(), message.to_string()];
583    if allow_empty {
584        args.push("--allow-empty".to_string());
585    }
586    if !files.is_empty() {
587        args.push("--only".to_string());
588        args.push("--".to_string());
589        args.extend(files.iter().cloned());
590    }
591
592    let output = run_git_owned(cwd, args).await?;
593    if !output.status.success() {
594        return Ok(git_failure("git commit failed", &output));
595    }
596
597    let head = head_sha_short(cwd)
598        .await
599        .unwrap_or_else(|| "unknown".to_string());
600    let parent = head_parent_sha_short(cwd).await;
601    let stdout = stdout_trimmed(&output);
602    let text = if stdout.is_empty() {
603        format!("Committed {head}: {message}")
604    } else {
605        stdout
606    };
607
608    Ok(ToolOutput {
609        content: vec![imp_llm::ContentBlock::Text { text: text.clone() }],
610        details: json!({
611            "action": "commit",
612            "repo_root": repo_root.display().to_string(),
613            "message": message,
614            "allow_empty": allow_empty,
615            "head": head,
616            "parent": parent,
617            "recovery": {
618                "commit": head,
619                "parent": parent,
620            },
621            "summary": text,
622        }),
623        is_error: false,
624    })
625}
626
627async fn targeted_commit_action(
628    cwd: &Path,
629    repo_root: &Path,
630    message: &str,
631    allow_empty: bool,
632    files: &[String],
633) -> Result<ToolOutput> {
634    let diff_output = run_git_owned(
635        cwd,
636        ["diff", "--quiet", "HEAD", "--"]
637            .into_iter()
638            .map(str::to_string)
639            .chain(files.iter().cloned())
640            .collect(),
641    )
642    .await?;
643    if diff_output.status.success() && !allow_empty {
644        return Ok(ToolOutput::error(format!(
645            "No changes to commit for targeted path(s): {}",
646            files.join(", ")
647        )));
648    }
649
650    let index_path = std::env::temp_dir().join(format!(
651        "imp-git-targeted-index-{}-{}",
652        std::process::id(),
653        unique_suffix()
654    ));
655    let index = index_path.to_string_lossy().to_string();
656
657    let read_tree = run_git_with_env(cwd, ["read-tree", "HEAD"], Some((&index, repo_root))).await?;
658    if !read_tree.status.success() {
659        cleanup_temp_index(&index_path);
660        return Ok(git_failure("git read-tree failed", &read_tree));
661    }
662
663    let mut add_args = vec!["add".to_string(), "--".to_string()];
664    add_args.extend(files.iter().cloned());
665    let add = run_git_owned_with_env(cwd, add_args, Some((&index, repo_root))).await?;
666    if !add.status.success() {
667        cleanup_temp_index(&index_path);
668        return Ok(git_failure("git add failed for targeted commit", &add));
669    }
670
671    let write_tree = run_git_with_env(cwd, ["write-tree"], Some((&index, repo_root))).await?;
672    if !write_tree.status.success() {
673        cleanup_temp_index(&index_path);
674        return Ok(git_failure("git write-tree failed", &write_tree));
675    }
676    let tree = stdout_trimmed(&write_tree);
677
678    if !allow_empty {
679        let head_tree = run_git(cwd, ["rev-parse", "HEAD^{tree}"]).await?;
680        if !head_tree.status.success() {
681            cleanup_temp_index(&index_path);
682            return Ok(git_failure("git rev-parse HEAD tree failed", &head_tree));
683        }
684        if stdout_trimmed(&head_tree) == tree {
685            cleanup_temp_index(&index_path);
686            return Ok(ToolOutput::error(format!(
687                "No changes to commit for targeted path(s): {}",
688                files.join(", ")
689            )));
690        }
691    }
692
693    let commit_tree = run_git_owned(
694        cwd,
695        vec![
696            "commit-tree".to_string(),
697            tree,
698            "-p".to_string(),
699            "HEAD".to_string(),
700            "-m".to_string(),
701            message.to_string(),
702        ],
703    )
704    .await?;
705    if !commit_tree.status.success() {
706        cleanup_temp_index(&index_path);
707        return Ok(git_failure("git commit-tree failed", &commit_tree));
708    }
709    let new_head = stdout_trimmed(&commit_tree);
710
711    let update_ref = run_git_owned(
712        cwd,
713        vec![
714            "update-ref".to_string(),
715            "-m".to_string(),
716            format!("commit: {message}"),
717            "HEAD".to_string(),
718            new_head.clone(),
719        ],
720    )
721    .await?;
722    cleanup_temp_index(&index_path);
723    if !update_ref.status.success() {
724        return Ok(git_failure("git update-ref failed", &update_ref));
725    }
726
727    let mut reset_args = vec![
728        "reset".to_string(),
729        "-q".to_string(),
730        "HEAD".to_string(),
731        "--".to_string(),
732    ];
733    reset_args.extend(files.iter().cloned());
734    let reset_index = run_git_owned(cwd, reset_args).await?;
735    if !reset_index.status.success() {
736        return Ok(git_failure(
737            "git reset failed after targeted commit",
738            &reset_index,
739        ));
740    }
741
742    let head = head_sha_short(cwd)
743        .await
744        .unwrap_or_else(|| "unknown".to_string());
745    let parent = head_parent_sha_short(cwd).await;
746    let summary = format!(
747        "Committed {head}: {message}\nIncluded targeted path(s): {}\nPreserved existing index and unrelated worktree changes.",
748        files.join(", ")
749    );
750
751    Ok(ToolOutput {
752        content: vec![imp_llm::ContentBlock::Text {
753            text: summary.clone(),
754        }],
755        details: json!({
756            "action": "commit",
757            "repo_root": repo_root.display().to_string(),
758            "message": message,
759            "allow_empty": allow_empty,
760            "files": files,
761            "preserve_index": true,
762            "head": head,
763            "parent": parent,
764            "recovery": {
765                "commit": head,
766                "parent": parent,
767            },
768            "summary": summary,
769        }),
770        is_error: false,
771    })
772}
773
774fn cleanup_temp_index(path: &Path) {
775    let _ = std::fs::remove_file(path);
776    let lock = path.with_extension("lock");
777    let _ = std::fs::remove_file(lock);
778}
779
780fn unique_suffix() -> u128 {
781    std::time::SystemTime::now()
782        .duration_since(std::time::UNIX_EPOCH)
783        .map(|duration| duration.as_nanos())
784        .unwrap_or(0)
785}
786
787async fn restore_action(
788    cwd: &Path,
789    repo_root: &Path,
790    params: &serde_json::Value,
791    ctx: &ToolContext,
792) -> Result<ToolOutput> {
793    let files = parse_string_array(params, "files")?;
794    if files.is_empty() {
795        return Ok(ToolOutput::error("restore requires files[]"));
796    }
797
798    let snapshot_paths: Vec<PathBuf> = files.iter().map(|file| resolve_path(cwd, file)).collect();
799    let checkpoint = ctx.checkpoint_state.snapshot_paths(
800        &snapshot_paths,
801        Some(format!("git restore in {}", cwd.display())),
802    )?;
803
804    let mut args = vec!["restore".to_string()];
805    if let Some(source) = non_empty_param(params, "source") {
806        validate_ref(source, "source")?;
807        args.push(format!("--source={source}"));
808    }
809    args.push("--".to_string());
810    args.extend(files.iter().cloned());
811
812    let output = run_git_owned(cwd, args).await?;
813    if !output.status.success() {
814        return Ok(git_failure("git restore failed", &output));
815    }
816
817    let summary = format!("Restored {} path(s)", files.len());
818    Ok(ToolOutput {
819        content: vec![imp_llm::ContentBlock::Text {
820            text: summary.clone(),
821        }],
822        details: json!({
823            "action": "restore",
824            "repo_root": repo_root.display().to_string(),
825            "files": files,
826            "checkpoint_id": checkpoint.as_ref().map(|c| c.id.clone()),
827            "checkpoint_label": checkpoint.as_ref().and_then(|c| c.label.clone()),
828            "recovery": {
829                "checkpoint_id": checkpoint.as_ref().map(|c| c.id.clone()),
830                "checkpoint_label": checkpoint.as_ref().and_then(|c| c.label.clone()),
831            },
832            "summary": summary,
833        }),
834        is_error: false,
835    })
836}
837
838fn parse_string_array(
839    params: &serde_json::Value,
840    field_name: &str,
841) -> std::result::Result<Vec<String>, crate::error::Error> {
842    let Some(value) = params.get(field_name) else {
843        return Ok(Vec::new());
844    };
845    let Some(items) = value.as_array() else {
846        return Err(crate::error::Error::Tool(format!(
847            "{field_name} must be an array of strings"
848        )));
849    };
850
851    let mut result = Vec::with_capacity(items.len());
852    for item in items {
853        let Some(s) = item.as_str().map(str::trim).filter(|s| !s.is_empty()) else {
854            return Err(crate::error::Error::Tool(format!(
855                "{field_name} must contain only non-empty strings"
856            )));
857        };
858        if s.chars().any(|c| c == '\0' || c.is_control()) {
859            return Err(crate::error::Error::Tool(format!(
860                "{field_name} must contain safe path strings"
861            )));
862        }
863        result.push(s.to_string());
864    }
865    Ok(result)
866}
867
868async fn head_parent_sha_short(cwd: &Path) -> Option<String> {
869    let output = run_git(cwd, ["rev-parse", "--short", "HEAD^"]).await.ok()?;
870    if !output.status.success() {
871        return None;
872    }
873    let parent = stdout_trimmed(&output);
874    if parent.is_empty() {
875        None
876    } else {
877        Some(parent)
878    }
879}
880
881async fn head_sha_short(cwd: &Path) -> Option<String> {
882    let output = run_git(cwd, ["rev-parse", "--short", "HEAD"]).await.ok()?;
883    if !output.status.success() {
884        return None;
885    }
886    let head = stdout_trimmed(&output);
887    if head.is_empty() {
888        None
889    } else {
890        Some(head)
891    }
892}
893
894async fn run_git<I, S>(cwd: &Path, args: I) -> std::io::Result<std::process::Output>
895where
896    I: IntoIterator<Item = S>,
897    S: AsRef<std::ffi::OsStr>,
898{
899    let mut command = Command::new("git");
900    command
901        .args(args)
902        .current_dir(cwd)
903        .stdin(Stdio::null())
904        .stdout(Stdio::piped())
905        .stderr(Stdio::piped());
906    command.output().await
907}
908
909async fn run_git_owned(cwd: &Path, args: Vec<String>) -> std::io::Result<std::process::Output> {
910    run_git(cwd, args).await
911}
912
913async fn run_git_with_env<I, S>(
914    cwd: &Path,
915    args: I,
916    temp_index: Option<(&str, &Path)>,
917) -> std::io::Result<std::process::Output>
918where
919    I: IntoIterator<Item = S>,
920    S: AsRef<std::ffi::OsStr>,
921{
922    let mut command = Command::new("git");
923    command
924        .args(args)
925        .current_dir(cwd)
926        .stdin(Stdio::null())
927        .stdout(Stdio::piped())
928        .stderr(Stdio::piped());
929    if let Some((index, work_tree)) = temp_index {
930        command
931            .env("GIT_INDEX_FILE", index)
932            .env("GIT_WORK_TREE", work_tree);
933    }
934    command.output().await
935}
936
937async fn run_git_owned_with_env(
938    cwd: &Path,
939    args: Vec<String>,
940    temp_index: Option<(&str, &Path)>,
941) -> std::io::Result<std::process::Output> {
942    run_git_with_env(cwd, args, temp_index).await
943}
944
945fn stdout_lossy(output: &std::process::Output) -> String {
946    String::from_utf8_lossy(&output.stdout).replace('\r', "")
947}
948
949fn stderr_lossy(output: &std::process::Output) -> String {
950    String::from_utf8_lossy(&output.stderr).replace('\r', "")
951}
952
953fn stdout_trimmed(output: &std::process::Output) -> String {
954    stdout_lossy(output).trim().to_string()
955}
956
957fn stderr_trimmed(output: &std::process::Output) -> String {
958    stderr_lossy(output).trim().to_string()
959}
960
961fn not_git_repo_message(cwd: &Path, output: &std::process::Output) -> String {
962    let stderr = stderr_trimmed(output);
963    if stderr.is_empty() {
964        format!("Not inside a git repository: {}", cwd.display())
965    } else {
966        format!("Not inside a git repository: {}\n{}", cwd.display(), stderr)
967    }
968}
969
970fn git_failure(prefix: &str, output: &std::process::Output) -> ToolOutput {
971    let stdout = stdout_trimmed(output);
972    let stderr = stderr_trimmed(output);
973    let combined = match (stdout.is_empty(), stderr.is_empty()) {
974        (true, true) => prefix.to_string(),
975        (false, true) => format!("{prefix}: {stdout}"),
976        (true, false) => format!("{prefix}: {stderr}"),
977        (false, false) => format!("{prefix}: {stdout}\n{stderr}"),
978    };
979    ToolOutput {
980        content: vec![imp_llm::ContentBlock::Text { text: combined }],
981        details: json!({
982            "success": false,
983            "exit_code": output.status.code(),
984            "stdout": stdout,
985            "stderr": stderr,
986        }),
987        is_error: true,
988    }
989}
990
991fn display_or_unknown(s: &str) -> &str {
992    if s.trim().is_empty() {
993        "unknown"
994    } else {
995        s
996    }
997}
998
999fn truncate_for_display(text: &str) -> (String, String, Option<PathBuf>) {
1000    let truncated = truncate_head(text, DISPLAY_MAX_LINES, DISPLAY_MAX_BYTES);
1001    let content = truncated.content.trim_end().to_string();
1002    let note = if truncated.truncated {
1003        let base = format!(
1004            "[output truncated: showing {}/{} lines, {}/{} bytes]",
1005            truncated.output_lines,
1006            truncated.total_lines,
1007            truncated.output_bytes,
1008            truncated.total_bytes,
1009        );
1010        match &truncated.temp_file {
1011            Some(path) => format!("{base} full output: {}", path.display()),
1012            None => base,
1013        }
1014    } else {
1015        String::new()
1016    };
1017    (content, note, truncated.temp_file)
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023    use crate::mana_review::TurnManaReviewAccumulator;
1024    use crate::tools::{CheckpointState, FileCache, FileTracker};
1025    use std::fs;
1026    use std::path::Path;
1027    use std::sync::Arc;
1028
1029    fn test_ctx(dir: &Path, mode: AgentMode) -> ToolContext {
1030        let (tx, _rx) = tokio::sync::mpsc::channel(16);
1031        let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
1032        ToolContext {
1033            cwd: dir.to_path_buf(),
1034            cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1035            update_tx: tx,
1036            command_tx: cmd_tx,
1037            ui: Arc::new(crate::ui::NullInterface),
1038            file_cache: Arc::new(FileCache::new()),
1039            checkpoint_state: Arc::new(CheckpointState::new()),
1040            file_tracker: Arc::new(std::sync::Mutex::new(FileTracker::new())),
1041            anchor_store: Arc::new(crate::tools::AnchorStore::new()),
1042            lua_tool_loader: None,
1043            mode,
1044            read_max_lines: 500,
1045            turn_mana_review: Arc::new(std::sync::Mutex::new(TurnManaReviewAccumulator::default())),
1046            config: Arc::new(crate::config::Config::default()),
1047            run_policy: Default::default(),
1048            supporting_provenance: Vec::new(),
1049        }
1050    }
1051
1052    fn run_git_output(dir: &Path, args: &[&str]) -> String {
1053        let output = std::process::Command::new("git")
1054            .args(args)
1055            .current_dir(dir)
1056            .output()
1057            .unwrap_or_else(|e| panic!("git {:?} failed to execute: {e}", args));
1058        assert!(
1059            output.status.success(),
1060            "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
1061            args,
1062            dir.display(),
1063            output.status.code(),
1064            String::from_utf8_lossy(&output.stdout),
1065            String::from_utf8_lossy(&output.stderr)
1066        );
1067        String::from_utf8_lossy(&output.stdout).trim().to_string()
1068    }
1069
1070    fn run_git(dir: &Path, args: &[&str]) {
1071        let output = std::process::Command::new("git")
1072            .args(args)
1073            .current_dir(dir)
1074            .output()
1075            .unwrap_or_else(|e| panic!("git {:?} failed to execute: {e}", args));
1076        assert!(
1077            output.status.success(),
1078            "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
1079            args,
1080            dir.display(),
1081            output.status.code(),
1082            String::from_utf8_lossy(&output.stdout),
1083            String::from_utf8_lossy(&output.stderr)
1084        );
1085    }
1086
1087    fn setup_repo() -> tempfile::TempDir {
1088        let dir = tempfile::tempdir().unwrap();
1089        run_git(dir.path(), &["init"]);
1090        run_git(dir.path(), &["config", "user.email", "test@test.com"]);
1091        run_git(dir.path(), &["config", "user.name", "Test User"]);
1092        fs::write(dir.path().join("note.txt"), "hello\n").unwrap();
1093        run_git(dir.path(), &["add", "-A"]);
1094        run_git(dir.path(), &["commit", "-m", "initial"]);
1095        dir
1096    }
1097
1098    fn extract_text(result: &ToolOutput) -> String {
1099        result.text_content().unwrap_or_default().to_string()
1100    }
1101
1102    #[test]
1103    fn schema_hides_worktree_actions_and_uses_snake_case_fields() {
1104        let schema = GitTool.parameters();
1105        let properties = schema["properties"].as_object().unwrap();
1106        let actions = properties["action"]["enum"].as_array().unwrap();
1107
1108        assert!(!actions.iter().any(|value| value == "worktree_info"));
1109        assert!(!actions.iter().any(|value| value == "worktree_add"));
1110        assert!(!actions.iter().any(|value| value == "worktree_remove"));
1111        assert!(properties.contains_key("all_changes"));
1112        assert!(!properties.contains_key("all"));
1113        assert!(properties.contains_key("allow_empty"));
1114        assert!(!properties.contains_key("allowEmpty"));
1115        assert!(!properties.contains_key("worktreePath"));
1116        assert_eq!(properties["limit"]["type"], json!("integer"));
1117        assert_eq!(properties["limit"]["maximum"], json!(100));
1118    }
1119
1120    #[tokio::test]
1121    async fn git_status_reports_clean_repo() {
1122        let dir = setup_repo();
1123        let tool = GitTool;
1124        let result = tool
1125            .execute(
1126                "c1",
1127                json!({"action": "status"}),
1128                test_ctx(dir.path(), AgentMode::Worker),
1129            )
1130            .await
1131            .unwrap();
1132
1133        assert!(!result.is_error);
1134        let text = extract_text(&result);
1135        assert!(text.contains("state: clean"));
1136        assert_eq!(result.details["clean"], json!(true));
1137    }
1138
1139    #[tokio::test]
1140    async fn git_diff_ignores_empty_ref_fields() {
1141        let dir = setup_repo();
1142        let tool = GitTool;
1143
1144        let result = tool
1145            .execute(
1146                "c-diff",
1147                json!({"action": "diff", "base": "", "head": ""}),
1148                test_ctx(dir.path(), AgentMode::Worker),
1149            )
1150            .await
1151            .unwrap();
1152
1153        assert!(!result.is_error);
1154        assert_eq!(extract_text(&result), "No diff.");
1155        assert_eq!(result.details["base"], json!(null));
1156        assert_eq!(result.details["head"], json!(null));
1157    }
1158
1159    #[tokio::test]
1160    async fn git_stage_and_commit_work() {
1161        let dir = setup_repo();
1162        fs::write(dir.path().join("note.txt"), "hello world\n").unwrap();
1163        let tool = GitTool;
1164
1165        let stage = tool
1166            .execute(
1167                "c-stage",
1168                json!({"action": "stage", "files": ["note.txt"]}),
1169                test_ctx(dir.path(), AgentMode::Worker),
1170            )
1171            .await
1172            .unwrap();
1173        assert!(!stage.is_error);
1174
1175        let commit = tool
1176            .execute(
1177                "c-commit",
1178                json!({"action": "commit", "message": "update note"}),
1179                test_ctx(dir.path(), AgentMode::Worker),
1180            )
1181            .await
1182            .unwrap();
1183        assert!(!commit.is_error);
1184        assert!(extract_text(&commit).contains("update note"));
1185
1186        let status = tool
1187            .execute(
1188                "c-status",
1189                json!({"action": "status"}),
1190                test_ctx(dir.path(), AgentMode::Worker),
1191            )
1192            .await
1193            .unwrap();
1194        assert!(!status.is_error);
1195        assert_eq!(status.details["clean"], json!(true));
1196    }
1197
1198    #[tokio::test]
1199    async fn git_stage_accepts_all_changes() {
1200        let dir = setup_repo();
1201        fs::write(dir.path().join("new.txt"), "new\n").unwrap();
1202        let tool = GitTool;
1203
1204        let result = tool
1205            .execute(
1206                "c-stage-all",
1207                json!({"action": "stage", "all_changes": true}),
1208                test_ctx(dir.path(), AgentMode::Worker),
1209            )
1210            .await
1211            .unwrap();
1212
1213        assert!(!result.is_error);
1214        assert_eq!(result.details["all_changes"], json!(true));
1215    }
1216
1217    #[tokio::test]
1218    async fn git_commit_accepts_allow_empty() {
1219        let dir = setup_repo();
1220        let tool = GitTool;
1221
1222        let result = tool
1223            .execute(
1224                "c-empty-commit",
1225                json!({"action": "commit", "message": "empty commit", "allow_empty": true}),
1226                test_ctx(dir.path(), AgentMode::Worker),
1227            )
1228            .await
1229            .unwrap();
1230
1231        assert!(!result.is_error);
1232        assert_eq!(result.details["allow_empty"], json!(true));
1233        assert!(extract_text(&result).contains("empty commit"));
1234    }
1235
1236    #[tokio::test]
1237    async fn targeted_commit_preserves_existing_index_and_unrelated_worktree() {
1238        let dir = setup_repo();
1239        fs::write(dir.path().join("target.txt"), "target base\n").unwrap();
1240        fs::write(dir.path().join("staged.txt"), "staged base\n").unwrap();
1241        fs::write(dir.path().join("dirty.txt"), "dirty base\n").unwrap();
1242        run_git(dir.path(), &["add", "-A"]);
1243        run_git(dir.path(), &["commit", "-m", "add fixtures"]);
1244
1245        fs::write(dir.path().join("target.txt"), "target changed\n").unwrap();
1246        fs::write(dir.path().join("staged.txt"), "staged changed\n").unwrap();
1247        fs::write(dir.path().join("dirty.txt"), "dirty changed\n").unwrap();
1248        run_git(dir.path(), &["add", "staged.txt"]);
1249
1250        let tool = GitTool;
1251        let result = tool
1252            .execute(
1253                "c-targeted-commit",
1254                json!({
1255                    "action": "commit",
1256                    "message": "update target only",
1257                    "files": ["target.txt"]
1258                }),
1259                test_ctx(dir.path(), AgentMode::Worker),
1260            )
1261            .await
1262            .unwrap();
1263
1264        assert!(!result.is_error, "{}", extract_text(&result));
1265        assert_eq!(result.details["preserve_index"], json!(true));
1266        assert!(extract_text(&result).contains("Included targeted path"));
1267
1268        let committed_files = run_git_output(
1269            dir.path(),
1270            &["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
1271        );
1272        assert_eq!(committed_files, "target.txt");
1273
1274        let status = run_git_output(dir.path(), &["status", "--porcelain=v1"]);
1275        assert!(
1276            status.lines().any(|line| line == "M  staged.txt"),
1277            "{status}"
1278        );
1279        assert!(
1280            !status.lines().any(|line| line.ends_with("target.txt")),
1281            "{status}"
1282        );
1283    }
1284
1285    #[tokio::test]
1286    async fn targeted_commit_rejects_noop_paths() {
1287        let dir = setup_repo();
1288        let tool = GitTool;
1289
1290        let result = tool
1291            .execute(
1292                "c-targeted-noop",
1293                json!({
1294                    "action": "commit",
1295                    "message": "noop target",
1296                    "files": ["note.txt"]
1297                }),
1298                test_ctx(dir.path(), AgentMode::Worker),
1299            )
1300            .await
1301            .unwrap();
1302
1303        assert!(result.is_error);
1304        assert!(extract_text(&result).contains("No changes to commit"));
1305        assert_eq!(
1306            run_git_output(dir.path(), &["rev-list", "--count", "HEAD"]),
1307            "1"
1308        );
1309    }
1310
1311    #[tokio::test]
1312    async fn git_restore_reverts_file_and_creates_checkpoint() {
1313        let dir = setup_repo();
1314        fs::write(dir.path().join("note.txt"), "changed\n").unwrap();
1315        let tool = GitTool;
1316        let ctx = test_ctx(dir.path(), AgentMode::Worker);
1317        let checkpoint_state = ctx.checkpoint_state.clone();
1318
1319        let result = tool
1320            .execute(
1321                "c-restore",
1322                json!({"action": "restore", "files": ["note.txt"]}),
1323                ctx,
1324            )
1325            .await
1326            .unwrap();
1327
1328        assert!(!result.is_error);
1329        assert_eq!(
1330            fs::read_to_string(dir.path().join("note.txt")).unwrap(),
1331            "hello\n"
1332        );
1333        assert_eq!(checkpoint_state.checkpoints().len(), 1);
1334        assert!(result.details["checkpoint_id"].as_str().is_some());
1335    }
1336
1337    #[tokio::test]
1338    async fn git_worktree_actions_point_to_worktree_tool() {
1339        let dir = setup_repo();
1340        let tool = GitTool;
1341
1342        let result = tool
1343            .execute(
1344                "c-info",
1345                json!({"action": "worktree_info"}),
1346                test_ctx(dir.path(), AgentMode::Worker),
1347            )
1348            .await
1349            .unwrap();
1350
1351        assert!(result.is_error);
1352        assert!(extract_text(&result).contains("worktree tool"));
1353    }
1354
1355    #[tokio::test]
1356    async fn planner_mode_blocks_mutating_git_actions() {
1357        let dir = setup_repo();
1358        let tool = GitTool;
1359        fs::write(dir.path().join("note.txt"), "changed\n").unwrap();
1360
1361        let result = tool
1362            .execute(
1363                "c-stage",
1364                json!({"action": "stage", "files": ["note.txt"]}),
1365                test_ctx(dir.path(), AgentMode::Planner),
1366            )
1367            .await
1368            .unwrap();
1369
1370        assert!(result.is_error);
1371        assert!(extract_text(&result).contains("not permitted"));
1372    }
1373
1374    #[tokio::test]
1375    async fn planner_mode_allows_readonly_git_actions() {
1376        let dir = setup_repo();
1377        let tool = GitTool;
1378
1379        let result = tool
1380            .execute(
1381                "c-status",
1382                json!({"action": "status"}),
1383                test_ctx(dir.path(), AgentMode::Planner),
1384            )
1385            .await
1386            .unwrap();
1387
1388        assert!(!result.is_error);
1389        assert!(extract_text(&result).contains("repo:"));
1390    }
1391}