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