Skip to main content

zeroclaw_tools/
git_operations.rs

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
9/// Git operations tool for structured repository management.
10/// Provides safe, parsed git operations with JSON output.
11pub 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    /// Sanitize git arguments to prevent injection attacks
25    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            // Block dangerous git options that could lead to command injection
29            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            // Block `-c` config injection (exact match or `-c=...` prefix).
45            // This must not false-positive on `--cached` or `-cached`.
46            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    /// Check if an operation requires write access
55    fn requires_write_access(&self, operation: &str) -> bool {
56        matches!(
57            operation,
58            "commit" | "add" | "checkout" | "stash" | "reset" | "revert" | "worktree"
59        )
60    }
61
62    /// Check if an operation is read-only
63    #[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    /// Resolve a user-provided path to an absolute path within the workspace.
72    /// Returns the workspace_dir if no path is provided.
73    /// Rejects paths that escape the workspace via traversal.
74    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        // Parse git status output into structured format
235        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                // Ordinary changed entry
246                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        // Validate files argument against injection patterns
292        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        // Parse diff into structured hunks
304        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        // Sanitize commit message.
474        // Trim trailing whitespace from each line but preserve blank lines —
475        // git uses the blank line between the subject and the body to separate
476        // them, so stripping blank lines collapses the entire message onto one
477        // line in `git log --oneline` and breaks `git log --format=%b`.
478        // We do strip leading blank lines and collapse runs of 3+ consecutive
479        // blank lines down to 2 (one blank line = paragraph break is fine;
480        // more than that is just noise).
481        let trimmed_lines: Vec<&str> = message.lines().map(|l| l.trim_end()).collect();
482        // Drop leading blank lines.
483        let trimmed_lines = trimmed_lines
484            .iter()
485            .copied()
486            .skip_while(|l| l.is_empty())
487            .collect::<Vec<_>>();
488        // Collapse runs of more than 2 consecutive blank lines to 2.
489        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        // Drop trailing blank lines.
503        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        // Limit message length
513        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        // Validate paths against injection patterns. Returns each
550        // whitespace-separated pathspec as its own argument so the join is
551        // not handed to git as a single literal path.
552        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        // Sanitize branch name
593        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        // Block dangerous branch names
602        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                // Build args: stash push [-m MSG] [-k] [--] [PATHSPEC...]
637                // `keep_index` preserves the staged area inside the working
638                // tree after stashing — needed to stash only unstaged
639                // changes and keep the index intact for the next commit.
640                // `paths` (space-separated) scopes the stash to specific
641                // pathspecs, leaving everything else untouched.
642                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    /// Parse `git worktree list --porcelain` output into structured format.
716    ///
717    /// Porcelain format emits one blank-line-delimited block per worktree:
718    ///   worktree <path>
719    ///   HEAD <hash>
720    ///   branch refs/heads/<name>   (or "detached")
721    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": &current_path,
736                        "branch": if is_detached { "HEAD" } else { &current_branch },
737                        "head": &current_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        // Flush final entry if output has no trailing blank line
757        if !current_path.is_empty() {
758            worktrees.push(json!({
759                "path": &current_path,
760                "branch": if is_detached { "HEAD" } else { current_branch.as_str() },
761                "head": &current_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                // git worktree add <path> [<branch>]
814                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        // Check if we're in a git repository
971        if !working_dir.join(".git").exists() {
972            // Try to find .git in parent directories
973            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        // Check autonomy level for write operations
993        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        // Record action for rate limiting
1017        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        // Execute the requested operation
1026        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    /// Initialise a git repo for tests with commit/tag signing disabled and a
1061    /// fixed identity. Tests run real `git commit`; without this they inherit
1062    /// the developer's global `commit.gpgsign`, blocking the suite on a
1063    /// hardware-key tap.
1064    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        // Should block dangerous arguments
1101        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        // Exact `-c` flag (config injection)
1123        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        // --cached must NOT be blocked by the `-c` check
1149        assert!(tool.sanitize_git_args("--cached").is_ok());
1150        // Other safe flags starting with -c prefix
1151        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        // Should allow safe arguments
1212        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        // worktree has write subcommands (add/remove), so it is not read-only
1247        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        // Branch listing is read-only; it must not require write access
1258        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        // can_act() returns false for ReadOnly, so we get the "higher autonomy level" message
1301        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        // Branch listing must not be blocked by read-only autonomy
1323        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        // This will fail because there's no git repo, but it shouldn't be blocked by autonomy
1340        let result = tool.execute(json!({"operation": "status"})).await.unwrap();
1341        // The error should be about git (not about autonomy/read-only mode)
1342        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    /// The blank line between the subject and body must be preserved so that
1385    /// `git log --format=%b` and `git log --oneline` both work correctly.
1386    /// Before the fix, `filter(|l| !l.is_empty())` stripped all blank lines
1387    /// and collapsed the whole message onto a single line.
1388    #[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        // Create an initial commit so HEAD exists.
1393        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        // Read back the raw commit message via git log.
1410        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        // Subject line must be on its own line.
1418        assert!(
1419            log_msg.starts_with("fix(foo): subject line\n"),
1420            "subject line missing or not first: {log_msg:?}"
1421        );
1422        // A blank line must follow the subject.
1423        assert!(
1424            log_msg.contains("fix(foo): subject line\n\n"),
1425            "blank line between subject and body missing: {log_msg:?}"
1426        );
1427        // Body text must be present.
1428        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    /// Helper: bootstrap a usable repo (init + identity + initial commit on
1534    /// `master`) so subsequent stash tests have something to stash against.
1535    /// `tracked_files` are added & committed so they appear as tracked
1536    /// modifications when later edited — `git stash` only handles tracked
1537    /// files by default, so all stash test fixtures must use this seam.
1538    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    /// `stash push` with no extra args stashes everything tracked — staged
1557    /// and unstaged. Regression guard: this is the legacy behaviour and
1558    /// must keep working when no `keep_index` / `paths` are supplied.
1559    #[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    /// `stash push` with `keep_index: true` stashes only unstaged changes
1592    /// and leaves the index intact. This is the fix for the tool's
1593    /// "stashes everything indiscriminately" bug.
1594    #[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        // `staged.txt` modification still present and staged (`M ` prefix);
1625        // `unstaged.txt` modification was stashed away — file matches HEAD.
1626        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    /// `stash push` with `paths` scopes the stash to specific pathspecs.
1637    /// Files outside the pathspec stay in the working tree.
1638    #[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    /// `stash push` with a custom `message` records that message instead
1678    /// of the default `auto-stash`.
1679    #[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    /// `stash push` with `include_untracked: true` also stashes untracked
1709    /// files — `git stash` ignores them by default.
1710    #[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}