1use async_trait::async_trait;
2use serde_json::json;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use zeroclaw_api::tool::{Tool, ToolResult};
6use zeroclaw_config::autonomy::AutonomyLevel;
7use zeroclaw_config::policy::SecurityPolicy;
8
9pub struct GitOperationsTool {
12 security: Arc<SecurityPolicy>,
13 workspace_dir: std::path::PathBuf,
14}
15
16impl GitOperationsTool {
17 pub fn new(security: Arc<SecurityPolicy>, workspace_dir: std::path::PathBuf) -> Self {
18 Self {
19 security,
20 workspace_dir,
21 }
22 }
23
24 fn sanitize_git_args(&self, args: &str) -> anyhow::Result<Vec<String>> {
26 let mut result = Vec::new();
27 for arg in args.split_whitespace() {
28 let arg_lower = arg.to_lowercase();
30 if arg_lower.starts_with("--exec=")
31 || arg_lower.starts_with("--upload-pack=")
32 || arg_lower.starts_with("--receive-pack=")
33 || arg_lower.starts_with("--pager=")
34 || arg_lower.starts_with("--editor=")
35 || arg_lower == "--no-verify"
36 || arg_lower.contains("$(")
37 || arg_lower.contains('`')
38 || arg.contains('|')
39 || arg.contains(';')
40 || arg.contains('>')
41 {
42 anyhow::bail!("Blocked potentially dangerous git argument: {arg}");
43 }
44 if arg_lower == "-c" || arg_lower.starts_with("-c=") {
47 anyhow::bail!("Blocked potentially dangerous git argument: {arg}");
48 }
49 result.push(arg.to_string());
50 }
51 Ok(result)
52 }
53
54 fn requires_write_access(&self, operation: &str) -> bool {
56 matches!(
57 operation,
58 "commit" | "add" | "checkout" | "stash" | "reset" | "revert" | "worktree"
59 )
60 }
61
62 #[cfg(test)]
64 fn is_read_only(&self, operation: &str) -> bool {
65 matches!(
66 operation,
67 "status" | "diff" | "log" | "show" | "branch" | "rev-parse"
68 )
69 }
70
71 fn resolve_working_dir(&self, path: Option<&str>) -> anyhow::Result<std::path::PathBuf> {
75 let base = match path {
76 Some(p) if !p.is_empty() => {
77 let candidate = if std::path::Path::new(p).is_absolute() {
78 std::path::PathBuf::from(p)
79 } else {
80 self.workspace_dir.join(p)
81 };
82 let resolved = candidate.canonicalize().map_err(|e| {
83 ::zeroclaw_log::record!(
84 WARN,
85 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
86 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
87 .with_attrs(::serde_json::json!({
88 "path": p,
89 "error": format!("{}", e),
90 })),
91 "git_operations: cannot resolve path"
92 );
93 anyhow::Error::msg(format!("Cannot resolve path '{}': {}", p, e))
94 })?;
95 let workspace_canonical = self
96 .workspace_dir
97 .canonicalize()
98 .unwrap_or_else(|_| self.workspace_dir.clone());
99 if !resolved.starts_with(&workspace_canonical) {
100 anyhow::bail!("Path '{}' resolves outside the workspace directory", p);
101 }
102 resolved
103 }
104 _ => self.workspace_dir.clone(),
105 };
106 Ok(base)
107 }
108
109 fn candidate_path(&self, raw_path: &str) -> anyhow::Result<PathBuf> {
110 if raw_path.contains('\0') {
111 anyhow::bail!("Path not allowed: contains null byte");
112 }
113 if Path::new(raw_path)
114 .components()
115 .any(|c| matches!(c, std::path::Component::ParentDir))
116 {
117 anyhow::bail!("Path not allowed: parent-directory traversal is not allowed");
118 }
119
120 let raw = Path::new(raw_path);
121 Ok(if raw.is_absolute() {
122 raw.to_path_buf()
123 } else {
124 self.workspace_dir.join(raw)
125 })
126 }
127
128 fn ensure_worktree_add_target_allowed(&self, raw_path: &str) -> anyhow::Result<PathBuf> {
129 let candidate = self.candidate_path(raw_path)?;
130 let parent = candidate.parent().ok_or_else(|| {
131 ::zeroclaw_log::record!(
132 WARN,
133 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
134 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
135 .with_attrs(::serde_json::json!({"raw_path": raw_path})),
136 "git_operations: worktree path has no parent"
137 );
138 anyhow::Error::msg("Worktree path must have a parent directory")
139 })?;
140 let file_name = candidate.file_name().ok_or_else(|| {
141 ::zeroclaw_log::record!(
142 WARN,
143 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
144 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
145 .with_attrs(::serde_json::json!({"raw_path": raw_path})),
146 "git_operations: worktree path has no file name"
147 );
148 anyhow::Error::msg("Worktree path must include a final path component")
149 })?;
150 let resolved_parent = parent.canonicalize().map_err(|e| {
151 ::zeroclaw_log::record!(
152 WARN,
153 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
154 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
155 .with_attrs(::serde_json::json!({
156 "parent": parent.display().to_string(),
157 "error": format!("{}", e),
158 })),
159 "git_operations: cannot resolve worktree parent"
160 );
161 anyhow::Error::msg(format!(
162 "Cannot resolve worktree parent '{}': {e}",
163 parent.display()
164 ))
165 })?;
166 let resolved_target = resolved_parent.join(file_name);
167
168 if !self.security.is_resolved_path_allowed(&resolved_target) {
169 anyhow::bail!(
170 "Worktree path '{}' resolves outside the workspace or allowed roots",
171 raw_path
172 );
173 }
174
175 Ok(resolved_target)
176 }
177
178 fn ensure_worktree_remove_target_allowed(&self, raw_path: &str) -> anyhow::Result<PathBuf> {
179 let candidate = self.candidate_path(raw_path)?;
180 let resolved = candidate.canonicalize().map_err(|e| {
181 ::zeroclaw_log::record!(
182 WARN,
183 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
184 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
185 .with_attrs(::serde_json::json!({
186 "raw_path": raw_path,
187 "error": format!("{}", e),
188 })),
189 "git_operations: cannot resolve worktree path"
190 );
191 anyhow::Error::msg(format!("Cannot resolve worktree path '{}': {e}", raw_path))
192 })?;
193
194 if !self.security.is_resolved_path_allowed(&resolved) {
195 anyhow::bail!(
196 "Worktree path '{}' resolves outside the workspace or allowed roots",
197 raw_path
198 );
199 }
200
201 Ok(resolved)
202 }
203
204 async fn run_git_command(
205 &self,
206 args: &[&str],
207 working_dir: &std::path::Path,
208 ) -> anyhow::Result<String> {
209 let output = tokio::process::Command::new("git")
210 .args(args)
211 .current_dir(working_dir)
212 .env("GIT_TERMINAL_PROMPT", "0")
213 .stdin(std::process::Stdio::null())
214 .output()
215 .await?;
216
217 if !output.status.success() {
218 let stderr = String::from_utf8_lossy(&output.stderr);
219 anyhow::bail!("Git command failed: {stderr}");
220 }
221
222 Ok(String::from_utf8_lossy(&output.stdout).to_string())
223 }
224
225 async fn git_status(
226 &self,
227 _args: serde_json::Value,
228 working_dir: &std::path::Path,
229 ) -> anyhow::Result<ToolResult> {
230 let output = self
231 .run_git_command(&["status", "--porcelain=2", "--branch"], working_dir)
232 .await?;
233
234 let mut result = serde_json::Map::new();
236 let mut branch = String::new();
237 let mut staged = Vec::new();
238 let mut unstaged = Vec::new();
239 let mut untracked = Vec::new();
240
241 for line in output.lines() {
242 if line.starts_with("# branch.head ") {
243 branch = line.trim_start_matches("# branch.head ").to_string();
244 } else if let Some(rest) = line.strip_prefix("1 ") {
245 let mut parts = rest.splitn(3, ' ');
247 if let (Some(staging), Some(path)) = (parts.next(), parts.next())
248 && !staging.is_empty()
249 {
250 let status_char = staging.chars().next().unwrap_or(' ');
251 if status_char != '.' && status_char != ' ' {
252 staged.push(json!({"path": path, "status": status_char}));
253 }
254 let status_char = staging.chars().nth(1).unwrap_or(' ');
255 if status_char != '.' && status_char != ' ' {
256 unstaged.push(json!({"path": path, "status": status_char}));
257 }
258 }
259 } else if let Some(rest) = line.strip_prefix("? ") {
260 untracked.push(rest.to_string());
261 }
262 }
263
264 result.insert("branch".to_string(), json!(branch));
265 result.insert("staged".to_string(), json!(staged));
266 result.insert("unstaged".to_string(), json!(unstaged));
267 result.insert("untracked".to_string(), json!(untracked));
268 result.insert(
269 "clean".to_string(),
270 json!(staged.is_empty() && unstaged.is_empty() && untracked.is_empty()),
271 );
272
273 Ok(ToolResult {
274 success: true,
275 output: serde_json::to_string_pretty(&result).unwrap_or_default(),
276 error: None,
277 })
278 }
279
280 async fn git_diff(
281 &self,
282 args: serde_json::Value,
283 working_dir: &std::path::Path,
284 ) -> anyhow::Result<ToolResult> {
285 let files = args.get("files").and_then(|v| v.as_str()).unwrap_or(".");
286 let cached = args
287 .get("cached")
288 .and_then(|v| v.as_bool())
289 .unwrap_or(false);
290
291 self.sanitize_git_args(files)?;
293
294 let mut git_args = vec!["diff", "--unified=3"];
295 if cached {
296 git_args.push("--cached");
297 }
298 git_args.push("--");
299 git_args.push(files);
300
301 let output = self.run_git_command(&git_args, working_dir).await?;
302
303 let mut result = serde_json::Map::new();
305 let mut hunks = Vec::new();
306 let mut current_file = String::new();
307 let mut current_hunk = serde_json::Map::new();
308 let mut lines = Vec::new();
309
310 for line in output.lines() {
311 if line.starts_with("diff --git ") {
312 if !lines.is_empty() {
313 current_hunk.insert("lines".to_string(), json!(lines));
314 if !current_hunk.is_empty() {
315 hunks.push(serde_json::Value::Object(current_hunk.clone()));
316 }
317 lines = Vec::new();
318 current_hunk = serde_json::Map::new();
319 }
320 let parts: Vec<&str> = line.split_whitespace().collect();
321 if parts.len() >= 4 {
322 current_file = parts[3].trim_start_matches("b/").to_string();
323 current_hunk.insert("file".to_string(), json!(current_file));
324 }
325 } else if line.starts_with("@@ ") {
326 if !lines.is_empty() {
327 current_hunk.insert("lines".to_string(), json!(lines));
328 if !current_hunk.is_empty() {
329 hunks.push(serde_json::Value::Object(current_hunk.clone()));
330 }
331 lines = Vec::new();
332 current_hunk = serde_json::Map::new();
333 current_hunk.insert("file".to_string(), json!(current_file));
334 }
335 current_hunk.insert("header".to_string(), json!(line));
336 } else if !line.is_empty() {
337 lines.push(json!({
338 "text": line,
339 "type": if line.starts_with('+') { "add" }
340 else if line.starts_with('-') { "delete" }
341 else { "context" }
342 }));
343 }
344 }
345
346 if !lines.is_empty() {
347 current_hunk.insert("lines".to_string(), json!(lines));
348 if !current_hunk.is_empty() {
349 hunks.push(serde_json::Value::Object(current_hunk));
350 }
351 }
352
353 result.insert("hunks".to_string(), json!(hunks));
354 result.insert("file_count".to_string(), json!(hunks.len()));
355
356 Ok(ToolResult {
357 success: true,
358 output: serde_json::to_string_pretty(&result).unwrap_or_default(),
359 error: None,
360 })
361 }
362
363 async fn git_log(
364 &self,
365 args: serde_json::Value,
366 working_dir: &std::path::Path,
367 ) -> anyhow::Result<ToolResult> {
368 let limit_raw = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10);
369 let limit = usize::try_from(limit_raw).unwrap_or(usize::MAX).min(1000);
370 let limit_str = limit.to_string();
371
372 let output = self
373 .run_git_command(
374 &[
375 "log",
376 &format!("-{limit_str}"),
377 "--pretty=format:%H|%an|%ae|%ad|%s",
378 "--date=iso",
379 ],
380 working_dir,
381 )
382 .await?;
383
384 let mut commits = Vec::new();
385
386 for line in output.lines() {
387 let parts: Vec<&str> = line.split('|').collect();
388 if parts.len() >= 5 {
389 commits.push(json!({
390 "hash": parts[0],
391 "author": parts[1],
392 "email": parts[2],
393 "date": parts[3],
394 "message": parts[4]
395 }));
396 }
397 }
398
399 Ok(ToolResult {
400 success: true,
401 output: serde_json::to_string_pretty(&json!({ "commits": commits }))
402 .unwrap_or_default(),
403 error: None,
404 })
405 }
406
407 async fn git_branch(
408 &self,
409 _args: serde_json::Value,
410 working_dir: &std::path::Path,
411 ) -> anyhow::Result<ToolResult> {
412 let output = self
413 .run_git_command(
414 &["branch", "--format=%(refname:short)|%(HEAD)"],
415 working_dir,
416 )
417 .await?;
418
419 let mut branches = Vec::new();
420 let mut current = String::new();
421
422 for line in output.lines() {
423 if let Some((name, head)) = line.split_once('|') {
424 let is_current = head == "*";
425 if is_current {
426 current = name.to_string();
427 }
428 branches.push(json!({
429 "name": name,
430 "current": is_current
431 }));
432 }
433 }
434
435 Ok(ToolResult {
436 success: true,
437 output: serde_json::to_string_pretty(&json!({
438 "current": current,
439 "branches": branches
440 }))
441 .unwrap_or_default(),
442 error: None,
443 })
444 }
445
446 fn truncate_commit_message(message: &str) -> String {
447 if message.chars().count() > 2000 {
448 format!("{}...", message.chars().take(1997).collect::<String>())
449 } else {
450 message.to_string()
451 }
452 }
453
454 async fn git_commit(
455 &self,
456 args: serde_json::Value,
457 working_dir: &std::path::Path,
458 ) -> anyhow::Result<ToolResult> {
459 let message = args
460 .get("message")
461 .and_then(|v| v.as_str())
462 .ok_or_else(|| {
463 ::zeroclaw_log::record!(
464 WARN,
465 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
466 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
467 .with_attrs(::serde_json::json!({"param": "message"})),
468 "git_operations: missing message parameter"
469 );
470 anyhow::Error::msg("Missing 'message' parameter")
471 })?;
472
473 let trimmed_lines: Vec<&str> = message.lines().map(|l| l.trim_end()).collect();
482 let trimmed_lines = trimmed_lines
484 .iter()
485 .copied()
486 .skip_while(|l| l.is_empty())
487 .collect::<Vec<_>>();
488 let mut sanitized_lines: Vec<&str> = Vec::with_capacity(trimmed_lines.len());
490 let mut consecutive_blanks = 0usize;
491 for line in &trimmed_lines {
492 if line.is_empty() {
493 consecutive_blanks += 1;
494 if consecutive_blanks <= 2 {
495 sanitized_lines.push(line);
496 }
497 } else {
498 consecutive_blanks = 0;
499 sanitized_lines.push(line);
500 }
501 }
502 while sanitized_lines.last().is_some_and(|l: &&str| l.is_empty()) {
504 sanitized_lines.pop();
505 }
506 let sanitized = sanitized_lines.join("\n");
507
508 if sanitized.is_empty() {
509 anyhow::bail!("Commit message cannot be empty");
510 }
511
512 let message = Self::truncate_commit_message(&sanitized);
514
515 let output = self
516 .run_git_command(&["commit", "-m", &message], working_dir)
517 .await;
518
519 match output {
520 Ok(_) => Ok(ToolResult {
521 success: true,
522 output: format!("Committed: {message}"),
523 error: None,
524 }),
525 Err(e) => Ok(ToolResult {
526 success: false,
527 output: String::new(),
528 error: Some(format!("Commit failed: {e}")),
529 }),
530 }
531 }
532
533 async fn git_add(
534 &self,
535 args: serde_json::Value,
536 working_dir: &std::path::Path,
537 ) -> anyhow::Result<ToolResult> {
538 let paths = args.get("paths").and_then(|v| v.as_str()).ok_or_else(|| {
539 ::zeroclaw_log::record!(
540 WARN,
541 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
542 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
543 .with_attrs(::serde_json::json!({"param": "paths"})),
544 "git_operations: missing paths parameter"
545 );
546 anyhow::Error::msg("Missing 'paths' parameter")
547 })?;
548
549 let sanitized = self.sanitize_git_args(paths)?;
553 if sanitized.is_empty() {
554 anyhow::bail!("No paths to stage");
555 }
556
557 let mut git_args: Vec<&str> = vec!["add", "--"];
558 git_args.extend(sanitized.iter().map(String::as_str));
559
560 let output = self.run_git_command(&git_args, working_dir).await;
561
562 match output {
563 Ok(_) => Ok(ToolResult {
564 success: true,
565 output: format!("Staged: {}", sanitized.join(" ")),
566 error: None,
567 }),
568 Err(e) => Ok(ToolResult {
569 success: false,
570 output: String::new(),
571 error: Some(format!("Add failed: {e}")),
572 }),
573 }
574 }
575
576 async fn git_checkout(
577 &self,
578 args: serde_json::Value,
579 working_dir: &std::path::Path,
580 ) -> anyhow::Result<ToolResult> {
581 let branch = args.get("branch").and_then(|v| v.as_str()).ok_or_else(|| {
582 ::zeroclaw_log::record!(
583 WARN,
584 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
585 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
586 .with_attrs(::serde_json::json!({"param": "branch"})),
587 "git_operations: missing branch parameter"
588 );
589 anyhow::Error::msg("Missing 'branch' parameter")
590 })?;
591
592 let sanitized = self.sanitize_git_args(branch)?;
594
595 if sanitized.is_empty() || sanitized.len() > 1 {
596 anyhow::bail!("Invalid branch specification");
597 }
598
599 let branch_name = &sanitized[0];
600
601 if branch_name.contains('@') || branch_name.contains('^') || branch_name.contains('~') {
603 anyhow::bail!("Branch name contains invalid characters");
604 }
605
606 let output = self
607 .run_git_command(&["checkout", branch_name], working_dir)
608 .await;
609
610 match output {
611 Ok(_) => Ok(ToolResult {
612 success: true,
613 output: format!("Switched to branch: {branch_name}"),
614 error: None,
615 }),
616 Err(e) => Ok(ToolResult {
617 success: false,
618 output: String::new(),
619 error: Some(format!("Checkout failed: {e}")),
620 }),
621 }
622 }
623
624 async fn git_stash(
625 &self,
626 args: serde_json::Value,
627 working_dir: &std::path::Path,
628 ) -> anyhow::Result<ToolResult> {
629 let action = args
630 .get("action")
631 .and_then(|v| v.as_str())
632 .unwrap_or("push");
633
634 let output = match action {
635 "push" | "save" => {
636 let message = args
643 .get("message")
644 .and_then(|v| v.as_str())
645 .unwrap_or("auto-stash")
646 .to_string();
647 let keep_index = args
648 .get("keep_index")
649 .and_then(|v| v.as_bool())
650 .unwrap_or(false);
651 let include_untracked = args
652 .get("include_untracked")
653 .and_then(|v| v.as_bool())
654 .unwrap_or(false);
655 let paths_raw = args
656 .get("paths")
657 .and_then(|v| v.as_str())
658 .unwrap_or("")
659 .trim()
660 .to_string();
661 let mut cmd: Vec<String> =
662 vec!["stash".into(), "push".into(), "-m".into(), message];
663 if keep_index {
664 cmd.push("-k".into());
665 }
666 if include_untracked {
667 cmd.push("-u".into());
668 }
669 if !paths_raw.is_empty() {
670 cmd.push("--".into());
671 for p in paths_raw.split_whitespace() {
672 cmd.push(p.to_string());
673 }
674 }
675 let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect();
676 self.run_git_command(&cmd_refs, working_dir).await
677 }
678 "pop" => self.run_git_command(&["stash", "pop"], working_dir).await,
679 "list" => self.run_git_command(&["stash", "list"], working_dir).await,
680 "drop" => {
681 let index_raw = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
682 let index = i32::try_from(index_raw).map_err(|_| {
683 ::zeroclaw_log::record!(
684 WARN,
685 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
686 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
687 .with_attrs(::serde_json::json!({"index": index_raw})),
688 "git_operations: stash index too large"
689 );
690 anyhow::Error::msg(format!("stash index too large: {index_raw}"))
691 })?;
692 self.run_git_command(
693 &["stash", "drop", &format!("stash@{{{index}}}")],
694 working_dir,
695 )
696 .await
697 }
698 _ => anyhow::bail!("Unknown stash action: {action}. Use: push, pop, list, drop"),
699 };
700
701 match output {
702 Ok(out) => Ok(ToolResult {
703 success: true,
704 output: out,
705 error: None,
706 }),
707 Err(e) => Ok(ToolResult {
708 success: false,
709 output: String::new(),
710 error: Some(format!("Stash {action} failed: {e}")),
711 }),
712 }
713 }
714
715 fn parse_worktree_list(&self, output: &str) -> serde_json::Value {
722 let mut worktrees = Vec::new();
723 let mut current_path = String::new();
724 let mut current_branch = String::new();
725 let mut current_head = String::new();
726 let mut is_detached = false;
727
728 let workspace = self.workspace_dir.to_string_lossy();
729
730 for line in output.lines() {
731 let line = line.trim();
732 if line.is_empty() {
733 if !current_path.is_empty() {
734 worktrees.push(json!({
735 "path": ¤t_path,
736 "branch": if is_detached { "HEAD" } else { ¤t_branch },
737 "head": ¤t_head,
738 "detached": is_detached,
739 "active": current_path == workspace.as_ref()
740 }));
741 current_path.clear();
742 current_branch.clear();
743 current_head.clear();
744 is_detached = false;
745 }
746 } else if let Some(p) = line.strip_prefix("worktree ") {
747 current_path = p.to_string();
748 } else if let Some(h) = line.strip_prefix("HEAD ") {
749 current_head = h.to_string();
750 } else if let Some(b) = line.strip_prefix("branch ") {
751 current_branch = b.trim_start_matches("refs/heads/").to_string();
752 } else if line == "detached" {
753 is_detached = true;
754 }
755 }
756 if !current_path.is_empty() {
758 worktrees.push(json!({
759 "path": ¤t_path,
760 "branch": if is_detached { "HEAD" } else { current_branch.as_str() },
761 "head": ¤t_head,
762 "detached": is_detached,
763 "active": current_path == workspace.as_ref()
764 }));
765 }
766
767 json!({ "worktrees": worktrees })
768 }
769
770 async fn git_worktree(
771 &self,
772 args: serde_json::Value,
773 working_dir: &std::path::Path,
774 ) -> anyhow::Result<ToolResult> {
775 let subcommand = match args.get("subcommand").and_then(|v| v.as_str()) {
776 Some(cmd) => cmd,
777 None => anyhow::bail!("Missing 'subcommand' parameter. Use: list, add, remove, prune"),
778 };
779
780 match subcommand {
781 "list" => {
782 let output = self
783 .run_git_command(&["worktree", "list", "--porcelain"], working_dir)
784 .await?;
785 let parsed = self.parse_worktree_list(&output);
786 Ok(ToolResult {
787 success: true,
788 output: serde_json::to_string_pretty(&parsed).unwrap_or_default(),
789 error: None,
790 })
791 }
792 "add" => {
793 let worktree_path = match args.get("worktree_path").and_then(|v| v.as_str()) {
794 Some(p) => p,
795 None => anyhow::bail!("Missing 'worktree_path' parameter for worktree add"),
796 };
797 self.sanitize_git_args(worktree_path)?;
798 let worktree_path = self.ensure_worktree_add_target_allowed(worktree_path)?;
799 let worktree_path = worktree_path.to_str().ok_or_else(|| {
800 ::zeroclaw_log::record!(
801 WARN,
802 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
803 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
804 "git_operations: worktree path not valid UTF-8"
805 );
806 anyhow::Error::msg("Worktree path must be valid UTF-8 for git execution")
807 })?;
808
809 let branch = args
810 .get("branch")
811 .and_then(|v| v.as_str())
812 .unwrap_or_default();
813 let mut git_args = vec!["worktree", "add", worktree_path];
815 if !branch.is_empty() {
816 self.sanitize_git_args(branch)?;
817 git_args.push(branch);
818 }
819
820 self.run_git_command(&git_args, working_dir).await?;
821 Ok(ToolResult {
822 success: true,
823 output: format!("Worktree added at: {worktree_path}"),
824 error: None,
825 })
826 }
827 "remove" => {
828 let worktree_path = match args.get("worktree_path").and_then(|v| v.as_str()) {
829 Some(p) => p,
830 None => anyhow::bail!("Missing 'worktree_path' parameter for worktree remove"),
831 };
832 self.sanitize_git_args(worktree_path)?;
833 let worktree_path = self.ensure_worktree_remove_target_allowed(worktree_path)?;
834 let worktree_path = worktree_path.to_str().ok_or_else(|| {
835 ::zeroclaw_log::record!(
836 WARN,
837 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
838 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
839 "git_operations: worktree path not valid UTF-8"
840 );
841 anyhow::Error::msg("Worktree path must be valid UTF-8 for git execution")
842 })?;
843
844 self.run_git_command(&["worktree", "remove", worktree_path], working_dir)
845 .await?;
846 Ok(ToolResult {
847 success: true,
848 output: format!("Worktree removed: {worktree_path}"),
849 error: None,
850 })
851 }
852 "prune" => {
853 self.run_git_command(&["worktree", "prune"], working_dir)
854 .await?;
855 Ok(ToolResult {
856 success: true,
857 output: "Worktree prune completed".to_string(),
858 error: None,
859 })
860 }
861 _ => anyhow::bail!(
862 "Unknown worktree subcommand: {subcommand}. Use: list, add, remove, prune"
863 ),
864 }
865 }
866}
867
868#[async_trait]
869impl Tool for GitOperationsTool {
870 fn name(&self) -> &str {
871 "git_operations"
872 }
873
874 fn description(&self) -> &str {
875 "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash, worktree). Provides parsed JSON output and integrates with security policy for autonomy controls."
876 }
877
878 fn parameters_schema(&self) -> serde_json::Value {
879 json!({
880 "type": "object",
881 "properties": {
882 "operation": {
883 "type": "string",
884 "enum": ["status", "diff", "log", "branch", "commit", "add", "checkout", "stash", "worktree"],
885 "description": "Git operation to perform"
886 },
887 "subcommand": {
888 "type": "string",
889 "enum": ["list", "add", "remove", "prune"],
890 "description": "Worktree subcommand"
891 },
892 "message": {
893 "type": "string",
894 "description": "Commit message (for 'commit' operation); stash message (for 'stash push', defaults to 'auto-stash')"
895 },
896 "paths": {
897 "type": "string",
898 "description": "Space-separated file paths. For 'add', files to stage. For 'stash push', pathspecs to scope the stash to — without this, the entire working tree is stashed."
899 },
900 "branch": {
901 "type": "string",
902 "description": "Branch name (for 'checkout' operation or 'worktree add' subcommand)"
903 },
904 "worktree_path": {
905 "type": "string",
906 "description": "Filesystem path for the worktree (for 'worktree add' and 'worktree remove' subcommands). Relative paths resolve under the workspace; absolute paths must stay inside the workspace or configured allowed roots."
907 },
908 "files": {
909 "type": "string",
910 "description": "File or path to diff (for 'diff' operation, default: '.')"
911 },
912 "cached": {
913 "type": "boolean",
914 "description": "Show staged changes (for 'diff' operation)"
915 },
916 "limit": {
917 "type": "integer",
918 "description": "Number of log entries (for 'log' operation, default: 10)"
919 },
920 "action": {
921 "type": "string",
922 "enum": ["push", "pop", "list", "drop"],
923 "description": "Stash action (for 'stash' operation)"
924 },
925 "index": {
926 "type": "integer",
927 "description": "Stash index (for 'stash' with 'drop' action)"
928 },
929 "keep_index": {
930 "type": "boolean",
931 "description": "For 'stash push': preserve staged changes in the working tree after stashing — only unstaged changes go into the stash."
932 },
933 "include_untracked": {
934 "type": "boolean",
935 "description": "For 'stash push': also stash untracked files (-u). Without this, `git stash push` only touches tracked files."
936 },
937 "path": {
938 "type": "string",
939 "description": "Optional subdirectory path within the workspace to run git operations in. Defaults to workspace root."
940 }
941 },
942 "required": ["operation"]
943 })
944 }
945
946 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
947 let operation = match args.get("operation").and_then(|v| v.as_str()) {
948 Some(op) => op,
949 None => {
950 return Ok(ToolResult {
951 success: false,
952 output: String::new(),
953 error: Some("Missing 'operation' parameter".into()),
954 });
955 }
956 };
957
958 let path = args.get("path").and_then(|v| v.as_str());
959 let working_dir = match self.resolve_working_dir(path) {
960 Ok(d) => d,
961 Err(e) => {
962 return Ok(ToolResult {
963 success: false,
964 output: String::new(),
965 error: Some(format!("Invalid path: {e}")),
966 });
967 }
968 };
969
970 if !working_dir.join(".git").exists() {
972 let mut current_dir = working_dir.as_path();
974 let mut found_git = false;
975 while current_dir.parent().is_some() {
976 if current_dir.join(".git").exists() {
977 found_git = true;
978 break;
979 }
980 current_dir = current_dir.parent().unwrap();
981 }
982
983 if !found_git {
984 return Ok(ToolResult {
985 success: false,
986 output: String::new(),
987 error: Some("Not in a git repository".into()),
988 });
989 }
990 }
991
992 if self.requires_write_access(operation) {
994 if !self.security.can_act() {
995 return Ok(ToolResult {
996 success: false,
997 output: String::new(),
998 error: Some(
999 "Action blocked: git write operations require higher autonomy level".into(),
1000 ),
1001 });
1002 }
1003
1004 match self.security.autonomy {
1005 AutonomyLevel::ReadOnly => {
1006 return Ok(ToolResult {
1007 success: false,
1008 output: String::new(),
1009 error: Some("Action blocked: read-only mode".into()),
1010 });
1011 }
1012 AutonomyLevel::Supervised | AutonomyLevel::Full => {}
1013 }
1014 }
1015
1016 if !self.security.record_action() {
1018 return Ok(ToolResult {
1019 success: false,
1020 output: String::new(),
1021 error: Some("Action blocked: rate limit exceeded".into()),
1022 });
1023 }
1024
1025 match operation {
1027 "status" => self.git_status(args, &working_dir).await,
1028 "diff" => self.git_diff(args, &working_dir).await,
1029 "log" => self.git_log(args, &working_dir).await,
1030 "branch" => self.git_branch(args, &working_dir).await,
1031 "commit" => self.git_commit(args, &working_dir).await,
1032 "add" => self.git_add(args, &working_dir).await,
1033 "checkout" => self.git_checkout(args, &working_dir).await,
1034 "stash" => self.git_stash(args, &working_dir).await,
1035 "worktree" => self.git_worktree(args, &working_dir).await,
1036 _ => Ok(ToolResult {
1037 success: false,
1038 output: String::new(),
1039 error: Some(format!("Unknown operation: {operation}")),
1040 }),
1041 }
1042 }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048 use tempfile::TempDir;
1049 use zeroclaw_config::policy::SecurityPolicy;
1050
1051 fn test_tool(dir: &std::path::Path) -> GitOperationsTool {
1052 let security = Arc::new(SecurityPolicy {
1053 autonomy: AutonomyLevel::Supervised,
1054 workspace_dir: dir.to_path_buf(),
1055 ..SecurityPolicy::default()
1056 });
1057 GitOperationsTool::new(security, dir.to_path_buf())
1058 }
1059
1060 fn git_init_no_sign(dir: &std::path::Path, extra_init: &[&str]) {
1065 let mut init = vec!["init"];
1066 init.extend_from_slice(extra_init);
1067 for args in [
1068 init.as_slice(),
1069 &["config", "user.email", "test@test.com"],
1070 &["config", "user.name", "Test"],
1071 &["config", "commit.gpgsign", "false"],
1072 &["config", "tag.gpgsign", "false"],
1073 ] {
1074 std::process::Command::new("git")
1075 .args(args)
1076 .current_dir(dir)
1077 .output()
1078 .unwrap();
1079 }
1080 }
1081
1082 fn test_tool_with_allowed_root(
1083 dir: &std::path::Path,
1084 allowed_root: std::path::PathBuf,
1085 ) -> GitOperationsTool {
1086 let security = Arc::new(SecurityPolicy {
1087 autonomy: AutonomyLevel::Supervised,
1088 workspace_dir: dir.to_path_buf(),
1089 allowed_roots: vec![allowed_root],
1090 ..SecurityPolicy::default()
1091 });
1092 GitOperationsTool::new(security, dir.to_path_buf())
1093 }
1094
1095 #[test]
1096 fn sanitize_git_blocks_injection() {
1097 let tmp = TempDir::new().unwrap();
1098 let tool = test_tool(tmp.path());
1099
1100 assert!(tool.sanitize_git_args("--exec=rm -rf /").is_err());
1102 assert!(tool.sanitize_git_args("$(echo pwned)").is_err());
1103 assert!(tool.sanitize_git_args("`malicious`").is_err());
1104 assert!(tool.sanitize_git_args("arg | cat").is_err());
1105 assert!(tool.sanitize_git_args("arg; rm file").is_err());
1106 }
1107
1108 #[test]
1109 fn sanitize_git_blocks_pager_editor_injection() {
1110 let tmp = TempDir::new().unwrap();
1111 let tool = test_tool(tmp.path());
1112
1113 assert!(tool.sanitize_git_args("--pager=less").is_err());
1114 assert!(tool.sanitize_git_args("--editor=vim").is_err());
1115 }
1116
1117 #[test]
1118 fn sanitize_git_blocks_config_injection() {
1119 let tmp = TempDir::new().unwrap();
1120 let tool = test_tool(tmp.path());
1121
1122 assert!(tool.sanitize_git_args("-c core.sshCommand=evil").is_err());
1124 assert!(tool.sanitize_git_args("-c=core.pager=less").is_err());
1125 }
1126
1127 #[test]
1128 fn sanitize_git_blocks_no_verify() {
1129 let tmp = TempDir::new().unwrap();
1130 let tool = test_tool(tmp.path());
1131
1132 assert!(tool.sanitize_git_args("--no-verify").is_err());
1133 }
1134
1135 #[test]
1136 fn sanitize_git_blocks_redirect_in_args() {
1137 let tmp = TempDir::new().unwrap();
1138 let tool = test_tool(tmp.path());
1139
1140 assert!(tool.sanitize_git_args("file.txt > /tmp/out").is_err());
1141 }
1142
1143 #[test]
1144 fn sanitize_git_cached_not_blocked() {
1145 let tmp = TempDir::new().unwrap();
1146 let tool = test_tool(tmp.path());
1147
1148 assert!(tool.sanitize_git_args("--cached").is_ok());
1150 assert!(tool.sanitize_git_args("-cached").is_ok());
1152 }
1153
1154 #[test]
1155 fn worktree_add_target_must_stay_inside_workspace_or_allowed_root() {
1156 let workspace = TempDir::new().unwrap();
1157 let outside = TempDir::new().unwrap();
1158 let tool = test_tool(workspace.path());
1159
1160 assert!(
1161 tool.ensure_worktree_add_target_allowed("new-worktree")
1162 .is_ok()
1163 );
1164 assert!(
1165 tool.ensure_worktree_add_target_allowed(
1166 outside.path().join("new-worktree").to_str().unwrap()
1167 )
1168 .is_err()
1169 );
1170 }
1171
1172 #[test]
1173 fn worktree_add_target_allows_configured_allowed_root() {
1174 let workspace = TempDir::new().unwrap();
1175 let allowed = TempDir::new().unwrap();
1176 let tool = test_tool_with_allowed_root(workspace.path(), allowed.path().to_path_buf());
1177
1178 assert!(
1179 tool.ensure_worktree_add_target_allowed(
1180 allowed.path().join("new-worktree").to_str().unwrap()
1181 )
1182 .is_ok()
1183 );
1184 }
1185
1186 #[test]
1187 fn worktree_remove_target_must_stay_inside_workspace() {
1188 let workspace = TempDir::new().unwrap();
1189 let outside = TempDir::new().unwrap();
1190 std::fs::create_dir(workspace.path().join("old-worktree")).unwrap();
1191 std::fs::create_dir(outside.path().join("old-worktree")).unwrap();
1192 let tool = test_tool(workspace.path());
1193
1194 assert!(
1195 tool.ensure_worktree_remove_target_allowed("old-worktree")
1196 .is_ok()
1197 );
1198 assert!(
1199 tool.ensure_worktree_remove_target_allowed(
1200 outside.path().join("old-worktree").to_str().unwrap()
1201 )
1202 .is_err()
1203 );
1204 }
1205
1206 #[test]
1207 fn sanitize_git_allows_safe() {
1208 let tmp = TempDir::new().unwrap();
1209 let tool = test_tool(tmp.path());
1210
1211 assert!(tool.sanitize_git_args("main").is_ok());
1213 assert!(tool.sanitize_git_args("feature/test-branch").is_ok());
1214 assert!(tool.sanitize_git_args("--cached").is_ok());
1215 assert!(tool.sanitize_git_args("src/main.rs").is_ok());
1216 assert!(tool.sanitize_git_args(".").is_ok());
1217 }
1218
1219 #[test]
1220 fn requires_write_detection() {
1221 let tmp = TempDir::new().unwrap();
1222 let tool = test_tool(tmp.path());
1223
1224 assert!(tool.requires_write_access("commit"));
1225 assert!(tool.requires_write_access("add"));
1226 assert!(tool.requires_write_access("checkout"));
1227 assert!(tool.requires_write_access("stash"));
1228 assert!(tool.requires_write_access("worktree"));
1229
1230 assert!(!tool.requires_write_access("status"));
1231 assert!(!tool.requires_write_access("diff"));
1232 assert!(!tool.requires_write_access("log"));
1233 assert!(!tool.requires_write_access("branch"));
1234 }
1235
1236 #[test]
1237 fn is_read_only_detection() {
1238 let tmp = TempDir::new().unwrap();
1239 let tool = test_tool(tmp.path());
1240
1241 assert!(tool.is_read_only("status"));
1242 assert!(tool.is_read_only("diff"));
1243 assert!(tool.is_read_only("log"));
1244 assert!(tool.is_read_only("branch"));
1245
1246 assert!(!tool.is_read_only("worktree"));
1248 assert!(!tool.is_read_only("commit"));
1249 assert!(!tool.is_read_only("add"));
1250 }
1251
1252 #[test]
1253 fn branch_is_not_write_gated() {
1254 let tmp = TempDir::new().unwrap();
1255 let tool = test_tool(tmp.path());
1256
1257 assert!(!tool.requires_write_access("branch"));
1259 assert!(tool.is_read_only("branch"));
1260 }
1261
1262 #[tokio::test]
1263 async fn git_credential_op_fails_fast_without_terminal_prompt() {
1264 let tmp = TempDir::new().unwrap();
1265 git_init_no_sign(tmp.path(), &[]);
1266 let tool = test_tool(tmp.path());
1267
1268 let fetch = tool.run_git_command(
1269 &["fetch", "https://127.0.0.1:1/private/repo.git"],
1270 tmp.path(),
1271 );
1272 let res = tokio::time::timeout(std::time::Duration::from_secs(10), fetch).await;
1273
1274 assert!(
1275 res.is_ok(),
1276 "git fetch hung — it likely prompted for credentials on the terminal"
1277 );
1278 assert!(
1279 res.unwrap().is_err(),
1280 "fetch to an unreachable private remote should fail, not succeed"
1281 );
1282 }
1283
1284 #[tokio::test]
1285 async fn blocks_readonly_mode_for_write_ops() {
1286 let tmp = TempDir::new().unwrap();
1287 git_init_no_sign(tmp.path(), &[]);
1288
1289 let security = Arc::new(SecurityPolicy {
1290 autonomy: AutonomyLevel::ReadOnly,
1291 ..SecurityPolicy::default()
1292 });
1293 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
1294
1295 let result = tool
1296 .execute(json!({"operation": "commit", "message": "test"}))
1297 .await
1298 .unwrap();
1299 assert!(!result.success);
1300 assert!(
1302 result
1303 .error
1304 .as_deref()
1305 .unwrap_or("")
1306 .contains("higher autonomy")
1307 );
1308 }
1309
1310 #[tokio::test]
1311 async fn allows_branch_listing_in_readonly_mode() {
1312 let tmp = TempDir::new().unwrap();
1313 git_init_no_sign(tmp.path(), &[]);
1314
1315 let security = Arc::new(SecurityPolicy {
1316 autonomy: AutonomyLevel::ReadOnly,
1317 ..SecurityPolicy::default()
1318 });
1319 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
1320
1321 let result = tool.execute(json!({"operation": "branch"})).await.unwrap();
1322 let error_msg = result.error.as_deref().unwrap_or("");
1324 assert!(
1325 !error_msg.contains("read-only") && !error_msg.contains("higher autonomy"),
1326 "branch listing should not be blocked in read-only mode, got: {error_msg}"
1327 );
1328 }
1329
1330 #[tokio::test]
1331 async fn allows_readonly_ops_in_readonly_mode() {
1332 let tmp = TempDir::new().unwrap();
1333 let security = Arc::new(SecurityPolicy {
1334 autonomy: AutonomyLevel::ReadOnly,
1335 ..SecurityPolicy::default()
1336 });
1337 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
1338
1339 let result = tool.execute(json!({"operation": "status"})).await.unwrap();
1341 assert!(!result.success, "Expected failure due to missing git repo");
1343 let error_msg = result.error.as_deref().unwrap_or("");
1344 assert!(
1345 !error_msg.contains("read-only") && !error_msg.contains("autonomy"),
1346 "Error should be about git, not about autonomy restrictions: {error_msg}"
1347 );
1348 }
1349
1350 #[tokio::test]
1351 async fn rejects_missing_operation() {
1352 let tmp = TempDir::new().unwrap();
1353 let tool = test_tool(tmp.path());
1354
1355 let result = tool.execute(json!({})).await.unwrap();
1356 assert!(!result.success);
1357 assert!(
1358 result
1359 .error
1360 .as_deref()
1361 .unwrap_or("")
1362 .contains("Missing 'operation'")
1363 );
1364 }
1365
1366 #[tokio::test]
1367 async fn rejects_unknown_operation() {
1368 let tmp = TempDir::new().unwrap();
1369 git_init_no_sign(tmp.path(), &[]);
1370
1371 let tool = test_tool(tmp.path());
1372
1373 let result = tool.execute(json!({"operation": "push"})).await.unwrap();
1374 assert!(!result.success);
1375 assert!(
1376 result
1377 .error
1378 .as_deref()
1379 .unwrap_or("")
1380 .contains("Unknown operation")
1381 );
1382 }
1383
1384 #[tokio::test]
1389 async fn commit_message_preserves_blank_line_between_subject_and_body() {
1390 let tmp = TempDir::new().unwrap();
1391 git_init_no_sign(tmp.path(), &[]);
1392 std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
1394 std::process::Command::new("git")
1395 .args(["add", "."])
1396 .current_dir(tmp.path())
1397 .output()
1398 .unwrap();
1399
1400 let tool = test_tool(tmp.path());
1401
1402 let msg = "fix(foo): subject line\n\nThis is the body paragraph.\n\nSecond paragraph.";
1403 let result = tool
1404 .execute(json!({"operation": "commit", "message": msg}))
1405 .await
1406 .unwrap();
1407 assert!(result.success, "commit failed: {:?}", result.error);
1408
1409 let log_out = std::process::Command::new("git")
1411 .args(["log", "-1", "--format=%B"])
1412 .current_dir(tmp.path())
1413 .output()
1414 .unwrap();
1415 let log_msg = String::from_utf8_lossy(&log_out.stdout);
1416
1417 assert!(
1419 log_msg.starts_with("fix(foo): subject line\n"),
1420 "subject line missing or not first: {log_msg:?}"
1421 );
1422 assert!(
1424 log_msg.contains("fix(foo): subject line\n\n"),
1425 "blank line between subject and body missing: {log_msg:?}"
1426 );
1427 assert!(
1429 log_msg.contains("This is the body paragraph."),
1430 "body paragraph missing: {log_msg:?}"
1431 );
1432 }
1433
1434 #[test]
1435 fn truncates_multibyte_commit_message_without_panicking() {
1436 let long = "🦀".repeat(2500);
1437 let truncated = GitOperationsTool::truncate_commit_message(&long);
1438
1439 assert_eq!(truncated.chars().count(), 2000);
1440 }
1441
1442 #[test]
1443 fn resolve_working_dir_none_returns_workspace() {
1444 let tmp = TempDir::new().unwrap();
1445 let tool = test_tool(tmp.path());
1446
1447 let result = tool.resolve_working_dir(None).unwrap();
1448 assert_eq!(result, tmp.path().to_path_buf());
1449 }
1450
1451 #[test]
1452 fn resolve_working_dir_empty_returns_workspace() {
1453 let tmp = TempDir::new().unwrap();
1454 let tool = test_tool(tmp.path());
1455
1456 let result = tool.resolve_working_dir(Some("")).unwrap();
1457 assert_eq!(result, tmp.path().to_path_buf());
1458 }
1459
1460 #[test]
1461 fn resolve_working_dir_valid_subdir() {
1462 let tmp = TempDir::new().unwrap();
1463 std::fs::create_dir(tmp.path().join("subproject")).unwrap();
1464 let tool = test_tool(tmp.path());
1465
1466 let result = tool.resolve_working_dir(Some("subproject")).unwrap();
1467 let expected = tmp.path().join("subproject").canonicalize().unwrap();
1468 assert_eq!(result, expected);
1469 }
1470
1471 #[test]
1472 fn resolve_working_dir_rejects_traversal() {
1473 let tmp = TempDir::new().unwrap();
1474 let tool = test_tool(tmp.path());
1475
1476 let result = tool.resolve_working_dir(Some(".."));
1477 assert!(result.is_err());
1478 let err_msg = result.unwrap_err().to_string();
1479 assert!(
1480 err_msg.contains("resolves outside the workspace"),
1481 "Expected traversal rejection, got: {err_msg}"
1482 );
1483 }
1484
1485 #[tokio::test]
1486 async fn git_operations_work_in_subdirectory() {
1487 let tmp = TempDir::new().unwrap();
1488 let sub = tmp.path().join("nested");
1489 std::fs::create_dir(&sub).unwrap();
1490 git_init_no_sign(&sub, &[]);
1491
1492 let tool = test_tool(tmp.path());
1493
1494 let result = tool
1495 .execute(json!({"operation": "status", "path": "nested"}))
1496 .await
1497 .unwrap();
1498 assert!(
1499 result.success,
1500 "Expected success, got error: {:?}",
1501 result.error
1502 );
1503 assert!(result.output.contains("branch"));
1504 }
1505
1506 #[tokio::test]
1507 async fn git_worktree_list_works() {
1508 let tmp = TempDir::new().unwrap();
1509 git_init_no_sign(tmp.path(), &[]);
1510
1511 let tool = test_tool(tmp.path());
1512
1513 let result = tool
1514 .execute(json!({"operation": "worktree", "subcommand": "list"}))
1515 .await
1516 .unwrap();
1517 assert!(result.success, "Expected success, got: {:?}", result.error);
1518
1519 let parsed: serde_json::Value = serde_json::from_str(&result.output).unwrap();
1520 let worktrees = parsed["worktrees"]
1521 .as_array()
1522 .expect("worktrees must be an array");
1523 assert!(
1524 !worktrees.is_empty(),
1525 "Expected at least the main worktree in the list"
1526 );
1527 assert!(
1528 worktrees[0]["path"].as_str().is_some_and(|p| !p.is_empty()),
1529 "Main worktree must have a non-empty path"
1530 );
1531 }
1532
1533 async fn bootstrap_repo(dir: &std::path::Path, tracked_files: &[&str]) {
1539 git_init_no_sign(dir, &["-b", "master"]);
1540 std::fs::write(dir.join("README.md"), "hello").unwrap();
1541 for f in tracked_files {
1542 std::fs::write(dir.join(f), "initial").unwrap();
1543 }
1544 std::process::Command::new("git")
1545 .args(["add", "."])
1546 .current_dir(dir)
1547 .output()
1548 .unwrap();
1549 std::process::Command::new("git")
1550 .args(["commit", "-m", "initial"])
1551 .current_dir(dir)
1552 .output()
1553 .unwrap();
1554 }
1555
1556 #[tokio::test]
1560 async fn stash_push_default_stashes_staged_and_unstaged() {
1561 let tmp = TempDir::new().unwrap();
1562 bootstrap_repo(tmp.path(), &["staged.txt", "unstaged.txt"]).await;
1563
1564 std::fs::write(tmp.path().join("staged.txt"), "s-modified").unwrap();
1565 std::fs::write(tmp.path().join("unstaged.txt"), "u-modified").unwrap();
1566 std::process::Command::new("git")
1567 .args(["add", "staged.txt"])
1568 .current_dir(tmp.path())
1569 .output()
1570 .unwrap();
1571
1572 let tool = test_tool(tmp.path());
1573 let result = tool
1574 .execute(json!({"operation": "stash", "action": "push"}))
1575 .await
1576 .unwrap();
1577 assert!(result.success, "stash push failed: {:?}", result.error);
1578
1579 let status = std::process::Command::new("git")
1580 .args(["status", "--porcelain"])
1581 .current_dir(tmp.path())
1582 .output()
1583 .unwrap();
1584 let status_out = String::from_utf8_lossy(&status.stdout);
1585 assert!(
1586 status_out.trim().is_empty(),
1587 "expected clean working tree after default stash, got: {status_out:?}"
1588 );
1589 }
1590
1591 #[tokio::test]
1595 async fn stash_push_with_keep_index_preserves_staged() {
1596 let tmp = TempDir::new().unwrap();
1597 bootstrap_repo(tmp.path(), &["staged.txt", "unstaged.txt"]).await;
1598
1599 std::fs::write(tmp.path().join("staged.txt"), "s-modified").unwrap();
1600 std::fs::write(tmp.path().join("unstaged.txt"), "u-modified").unwrap();
1601 std::process::Command::new("git")
1602 .args(["add", "staged.txt"])
1603 .current_dir(tmp.path())
1604 .output()
1605 .unwrap();
1606
1607 let tool = test_tool(tmp.path());
1608 let result = tool
1609 .execute(json!({
1610 "operation": "stash",
1611 "action": "push",
1612 "keep_index": true,
1613 }))
1614 .await
1615 .unwrap();
1616 assert!(result.success, "stash push -k failed: {:?}", result.error);
1617
1618 let status = std::process::Command::new("git")
1619 .args(["status", "--porcelain"])
1620 .current_dir(tmp.path())
1621 .output()
1622 .unwrap();
1623 let status_out = String::from_utf8_lossy(&status.stdout).to_string();
1624 assert!(
1627 status_out.contains("M staged.txt"),
1628 "staged modification should remain staged, status: {status_out:?}"
1629 );
1630 assert!(
1631 !status_out.contains("unstaged.txt"),
1632 "unstaged modification should have been stashed, status: {status_out:?}"
1633 );
1634 }
1635
1636 #[tokio::test]
1639 async fn stash_push_with_paths_scopes_to_pathspec() {
1640 let tmp = TempDir::new().unwrap();
1641 bootstrap_repo(tmp.path(), &["a.txt", "b.txt"]).await;
1642
1643 std::fs::write(tmp.path().join("a.txt"), "a-modified").unwrap();
1644 std::fs::write(tmp.path().join("b.txt"), "b-modified").unwrap();
1645
1646 let tool = test_tool(tmp.path());
1647 let result = tool
1648 .execute(json!({
1649 "operation": "stash",
1650 "action": "push",
1651 "paths": "a.txt",
1652 }))
1653 .await
1654 .unwrap();
1655 assert!(
1656 result.success,
1657 "stash push -- a.txt failed: {:?}",
1658 result.error
1659 );
1660
1661 let status = std::process::Command::new("git")
1662 .args(["status", "--porcelain"])
1663 .current_dir(tmp.path())
1664 .output()
1665 .unwrap();
1666 let status_out = String::from_utf8_lossy(&status.stdout).to_string();
1667 assert!(
1668 !status_out.contains("a.txt"),
1669 "a.txt should have been stashed, status: {status_out:?}"
1670 );
1671 assert!(
1672 status_out.contains("b.txt"),
1673 "b.txt should remain modified, status: {status_out:?}"
1674 );
1675 }
1676
1677 #[tokio::test]
1680 async fn stash_push_with_custom_message() {
1681 let tmp = TempDir::new().unwrap();
1682 bootstrap_repo(tmp.path(), &["a.txt"]).await;
1683 std::fs::write(tmp.path().join("a.txt"), "a-modified").unwrap();
1684
1685 let tool = test_tool(tmp.path());
1686 let result = tool
1687 .execute(json!({
1688 "operation": "stash",
1689 "action": "push",
1690 "message": "scoped-fix-wip",
1691 }))
1692 .await
1693 .unwrap();
1694 assert!(result.success, "stash push -m failed: {:?}", result.error);
1695
1696 let list = std::process::Command::new("git")
1697 .args(["stash", "list"])
1698 .current_dir(tmp.path())
1699 .output()
1700 .unwrap();
1701 let list_out = String::from_utf8_lossy(&list.stdout).to_string();
1702 assert!(
1703 list_out.contains("scoped-fix-wip"),
1704 "custom stash message missing from list, got: {list_out:?}"
1705 );
1706 }
1707
1708 #[tokio::test]
1711 async fn stash_push_with_include_untracked_captures_new_files() {
1712 let tmp = TempDir::new().unwrap();
1713 bootstrap_repo(tmp.path(), &[]).await;
1714 std::fs::write(tmp.path().join("new.txt"), "untracked").unwrap();
1715
1716 let tool = test_tool(tmp.path());
1717 let result = tool
1718 .execute(json!({
1719 "operation": "stash",
1720 "action": "push",
1721 "include_untracked": true,
1722 }))
1723 .await
1724 .unwrap();
1725 assert!(result.success, "stash push -u failed: {:?}", result.error);
1726
1727 let status = std::process::Command::new("git")
1728 .args(["status", "--porcelain"])
1729 .current_dir(tmp.path())
1730 .output()
1731 .unwrap();
1732 let status_out = String::from_utf8_lossy(&status.stdout);
1733 assert!(
1734 status_out.trim().is_empty(),
1735 "expected clean tree after -u stash, got: {status_out:?}"
1736 );
1737 }
1738
1739 #[tokio::test]
1740 async fn add_stages_multiple_space_separated_paths() {
1741 let tmp = TempDir::new().unwrap();
1742 git_init_no_sign(tmp.path(), &[]);
1743 std::fs::write(tmp.path().join("a.txt"), "a").unwrap();
1744 std::fs::write(tmp.path().join("b.txt"), "b").unwrap();
1745
1746 let security = Arc::new(SecurityPolicy {
1747 autonomy: AutonomyLevel::Full,
1748 workspace_dir: tmp.path().to_path_buf(),
1749 ..SecurityPolicy::default()
1750 });
1751 let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
1752
1753 let result = tool
1754 .execute(json!({"operation": "add", "paths": "a.txt b.txt"}))
1755 .await
1756 .unwrap();
1757 assert!(result.success, "add failed: {:?}", result.error);
1758
1759 let status = std::process::Command::new("git")
1760 .args(["status", "--porcelain"])
1761 .current_dir(tmp.path())
1762 .output()
1763 .unwrap();
1764 let out = String::from_utf8_lossy(&status.stdout);
1765 assert!(out.contains("A a.txt"), "a.txt not staged: {out:?}");
1766 assert!(out.contains("A b.txt"), "b.txt not staged: {out:?}");
1767 }
1768}