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, ¶ms).await,
158 "log" => log_action(&cwd, &repo_root, ¶ms).await,
159 "merge_base" => merge_base_action(&cwd, &repo_root, ¶ms).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, ¶ms).await,
164 "commit" => commit_action(&cwd, &repo_root, ¶ms).await,
165 "restore" => restore_action(&cwd, &repo_root, ¶ms, &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}