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, ¶ms).await,
183 "log" => log_action(&cwd, &repo_root, ¶ms).await,
184 "merge_base" => merge_base_action(&cwd, &repo_root, ¶ms).await,
185 "worktree_info" => worktree_info_action(&cwd, &repo_root).await,
186 "stage" => stage_action(&cwd, &repo_root, ¶ms).await,
187 "commit" => commit_action(&cwd, &repo_root, ¶ms).await,
188 "restore" => restore_action(&cwd, &repo_root, ¶ms, &ctx).await,
189 "worktree_add" => worktree_add_action(&cwd, &repo_root, ¶ms).await,
190 "worktree_remove" => worktree_remove_action(&cwd, &repo_root, ¶ms).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 ¤t_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}