Skip to main content

construct/tools/
git_operations.rs

1use super::traits::{Tool, ToolResult};
2use crate::security::{AutonomyLevel, SecurityPolicy};
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
6
7/// Git operations tool for structured repository management.
8/// Provides safe, parsed git operations with JSON output.
9pub struct GitOperationsTool {
10    security: Arc<SecurityPolicy>,
11    workspace_dir: std::path::PathBuf,
12}
13
14impl GitOperationsTool {
15    pub fn new(security: Arc<SecurityPolicy>, workspace_dir: std::path::PathBuf) -> Self {
16        Self {
17            security,
18            workspace_dir,
19        }
20    }
21
22    /// Sanitize git arguments to prevent injection attacks
23    fn sanitize_git_args(&self, args: &str) -> anyhow::Result<Vec<String>> {
24        let mut result = Vec::new();
25        for arg in args.split_whitespace() {
26            // Block dangerous git options that could lead to command injection
27            let arg_lower = arg.to_lowercase();
28            if arg_lower.starts_with("--exec=")
29                || arg_lower.starts_with("--upload-pack=")
30                || arg_lower.starts_with("--receive-pack=")
31                || arg_lower.starts_with("--pager=")
32                || arg_lower.starts_with("--editor=")
33                || arg_lower == "--no-verify"
34                || arg_lower.contains("$(")
35                || arg_lower.contains('`')
36                || arg.contains('|')
37                || arg.contains(';')
38                || arg.contains('>')
39            {
40                anyhow::bail!("Blocked potentially dangerous git argument: {arg}");
41            }
42            // Block `-c` config injection (exact match or `-c=...` prefix).
43            // This must not false-positive on `--cached` or `-cached`.
44            if arg_lower == "-c" || arg_lower.starts_with("-c=") {
45                anyhow::bail!("Blocked potentially dangerous git argument: {arg}");
46            }
47            result.push(arg.to_string());
48        }
49        Ok(result)
50    }
51
52    /// Check if an operation requires write access
53    fn requires_write_access(&self, operation: &str) -> bool {
54        matches!(
55            operation,
56            "commit" | "add" | "checkout" | "stash" | "reset" | "revert"
57        )
58    }
59
60    /// Check if an operation is read-only
61    fn is_read_only(&self, operation: &str) -> bool {
62        matches!(
63            operation,
64            "status" | "diff" | "log" | "show" | "branch" | "rev-parse"
65        )
66    }
67
68    /// Resolve a user-provided path to an absolute path within the workspace.
69    /// Returns the workspace_dir if no path is provided.
70    /// Rejects paths that escape the workspace via traversal.
71    fn resolve_working_dir(&self, path: Option<&str>) -> anyhow::Result<std::path::PathBuf> {
72        let base = match path {
73            Some(p) if !p.is_empty() => {
74                let candidate = if std::path::Path::new(p).is_absolute() {
75                    std::path::PathBuf::from(p)
76                } else {
77                    self.workspace_dir.join(p)
78                };
79                let resolved = candidate
80                    .canonicalize()
81                    .map_err(|e| anyhow::anyhow!("Cannot resolve path '{}': {}", p, e))?;
82                let workspace_canonical = self
83                    .workspace_dir
84                    .canonicalize()
85                    .unwrap_or_else(|_| self.workspace_dir.clone());
86                if !resolved.starts_with(&workspace_canonical) {
87                    anyhow::bail!("Path '{}' resolves outside the workspace directory", p);
88                }
89                resolved
90            }
91            _ => self.workspace_dir.clone(),
92        };
93        Ok(base)
94    }
95
96    async fn run_git_command(
97        &self,
98        args: &[&str],
99        working_dir: &std::path::Path,
100    ) -> anyhow::Result<String> {
101        let output = tokio::process::Command::new("git")
102            .args(args)
103            .current_dir(working_dir)
104            .output()
105            .await?;
106
107        if !output.status.success() {
108            let stderr = String::from_utf8_lossy(&output.stderr);
109            anyhow::bail!("Git command failed: {stderr}");
110        }
111
112        Ok(String::from_utf8_lossy(&output.stdout).to_string())
113    }
114
115    async fn git_status(
116        &self,
117        _args: serde_json::Value,
118        working_dir: &std::path::Path,
119    ) -> anyhow::Result<ToolResult> {
120        let output = self
121            .run_git_command(&["status", "--porcelain=2", "--branch"], working_dir)
122            .await?;
123
124        // Parse git status output into structured format
125        let mut result = serde_json::Map::new();
126        let mut branch = String::new();
127        let mut staged = Vec::new();
128        let mut unstaged = Vec::new();
129        let mut untracked = Vec::new();
130
131        for line in output.lines() {
132            if line.starts_with("# branch.head ") {
133                branch = line.trim_start_matches("# branch.head ").to_string();
134            } else if let Some(rest) = line.strip_prefix("1 ") {
135                // Ordinary changed entry
136                let mut parts = rest.splitn(3, ' ');
137                if let (Some(staging), Some(path)) = (parts.next(), parts.next()) {
138                    if !staging.is_empty() {
139                        let status_char = staging.chars().next().unwrap_or(' ');
140                        if status_char != '.' && status_char != ' ' {
141                            staged.push(json!({"path": path, "status": status_char}));
142                        }
143                        let status_char = staging.chars().nth(1).unwrap_or(' ');
144                        if status_char != '.' && status_char != ' ' {
145                            unstaged.push(json!({"path": path, "status": status_char}));
146                        }
147                    }
148                }
149            } else if let Some(rest) = line.strip_prefix("? ") {
150                untracked.push(rest.to_string());
151            }
152        }
153
154        result.insert("branch".to_string(), json!(branch));
155        result.insert("staged".to_string(), json!(staged));
156        result.insert("unstaged".to_string(), json!(unstaged));
157        result.insert("untracked".to_string(), json!(untracked));
158        result.insert(
159            "clean".to_string(),
160            json!(staged.is_empty() && unstaged.is_empty() && untracked.is_empty()),
161        );
162
163        Ok(ToolResult {
164            success: true,
165            output: serde_json::to_string_pretty(&result).unwrap_or_default(),
166            error: None,
167        })
168    }
169
170    async fn git_diff(
171        &self,
172        args: serde_json::Value,
173        working_dir: &std::path::Path,
174    ) -> anyhow::Result<ToolResult> {
175        let files = args.get("files").and_then(|v| v.as_str()).unwrap_or(".");
176        let cached = args
177            .get("cached")
178            .and_then(|v| v.as_bool())
179            .unwrap_or(false);
180
181        // Validate files argument against injection patterns
182        self.sanitize_git_args(files)?;
183
184        let mut git_args = vec!["diff", "--unified=3"];
185        if cached {
186            git_args.push("--cached");
187        }
188        git_args.push("--");
189        git_args.push(files);
190
191        let output = self.run_git_command(&git_args, working_dir).await?;
192
193        // Parse diff into structured hunks
194        let mut result = serde_json::Map::new();
195        let mut hunks = Vec::new();
196        let mut current_file = String::new();
197        let mut current_hunk = serde_json::Map::new();
198        let mut lines = Vec::new();
199
200        for line in output.lines() {
201            if line.starts_with("diff --git ") {
202                if !lines.is_empty() {
203                    current_hunk.insert("lines".to_string(), json!(lines));
204                    if !current_hunk.is_empty() {
205                        hunks.push(serde_json::Value::Object(current_hunk.clone()));
206                    }
207                    lines = Vec::new();
208                    current_hunk = serde_json::Map::new();
209                }
210                let parts: Vec<&str> = line.split_whitespace().collect();
211                if parts.len() >= 4 {
212                    current_file = parts[3].trim_start_matches("b/").to_string();
213                    current_hunk.insert("file".to_string(), json!(current_file));
214                }
215            } else if line.starts_with("@@ ") {
216                if !lines.is_empty() {
217                    current_hunk.insert("lines".to_string(), json!(lines));
218                    if !current_hunk.is_empty() {
219                        hunks.push(serde_json::Value::Object(current_hunk.clone()));
220                    }
221                    lines = Vec::new();
222                    current_hunk = serde_json::Map::new();
223                    current_hunk.insert("file".to_string(), json!(current_file));
224                }
225                current_hunk.insert("header".to_string(), json!(line));
226            } else if !line.is_empty() {
227                lines.push(json!({
228                    "text": line,
229                    "type": if line.starts_with('+') { "add" }
230                           else if line.starts_with('-') { "delete" }
231                           else { "context" }
232                }));
233            }
234        }
235
236        if !lines.is_empty() {
237            current_hunk.insert("lines".to_string(), json!(lines));
238            if !current_hunk.is_empty() {
239                hunks.push(serde_json::Value::Object(current_hunk));
240            }
241        }
242
243        result.insert("hunks".to_string(), json!(hunks));
244        result.insert("file_count".to_string(), json!(hunks.len()));
245
246        Ok(ToolResult {
247            success: true,
248            output: serde_json::to_string_pretty(&result).unwrap_or_default(),
249            error: None,
250        })
251    }
252
253    async fn git_log(
254        &self,
255        args: serde_json::Value,
256        working_dir: &std::path::Path,
257    ) -> anyhow::Result<ToolResult> {
258        let limit_raw = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10);
259        let limit = usize::try_from(limit_raw).unwrap_or(usize::MAX).min(1000);
260        let limit_str = limit.to_string();
261
262        let output = self
263            .run_git_command(
264                &[
265                    "log",
266                    &format!("-{limit_str}"),
267                    "--pretty=format:%H|%an|%ae|%ad|%s",
268                    "--date=iso",
269                ],
270                working_dir,
271            )
272            .await?;
273
274        let mut commits = Vec::new();
275
276        for line in output.lines() {
277            let parts: Vec<&str> = line.split('|').collect();
278            if parts.len() >= 5 {
279                commits.push(json!({
280                    "hash": parts[0],
281                    "author": parts[1],
282                    "email": parts[2],
283                    "date": parts[3],
284                    "message": parts[4]
285                }));
286            }
287        }
288
289        Ok(ToolResult {
290            success: true,
291            output: serde_json::to_string_pretty(&json!({ "commits": commits }))
292                .unwrap_or_default(),
293            error: None,
294        })
295    }
296
297    async fn git_branch(
298        &self,
299        _args: serde_json::Value,
300        working_dir: &std::path::Path,
301    ) -> anyhow::Result<ToolResult> {
302        let output = self
303            .run_git_command(
304                &["branch", "--format=%(refname:short)|%(HEAD)"],
305                working_dir,
306            )
307            .await?;
308
309        let mut branches = Vec::new();
310        let mut current = String::new();
311
312        for line in output.lines() {
313            if let Some((name, head)) = line.split_once('|') {
314                let is_current = head == "*";
315                if is_current {
316                    current = name.to_string();
317                }
318                branches.push(json!({
319                    "name": name,
320                    "current": is_current
321                }));
322            }
323        }
324
325        Ok(ToolResult {
326            success: true,
327            output: serde_json::to_string_pretty(&json!({
328                "current": current,
329                "branches": branches
330            }))
331            .unwrap_or_default(),
332            error: None,
333        })
334    }
335
336    fn truncate_commit_message(message: &str) -> String {
337        if message.chars().count() > 2000 {
338            format!("{}...", message.chars().take(1997).collect::<String>())
339        } else {
340            message.to_string()
341        }
342    }
343
344    async fn git_commit(
345        &self,
346        args: serde_json::Value,
347        working_dir: &std::path::Path,
348    ) -> anyhow::Result<ToolResult> {
349        let message = args
350            .get("message")
351            .and_then(|v| v.as_str())
352            .ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))?;
353
354        // Sanitize commit message
355        let sanitized = message
356            .lines()
357            .map(|l| l.trim())
358            .filter(|l| !l.is_empty())
359            .collect::<Vec<_>>()
360            .join("\n");
361
362        if sanitized.is_empty() {
363            anyhow::bail!("Commit message cannot be empty");
364        }
365
366        // Limit message length
367        let message = Self::truncate_commit_message(&sanitized);
368
369        let output = self
370            .run_git_command(&["commit", "-m", &message], working_dir)
371            .await;
372
373        match output {
374            Ok(_) => Ok(ToolResult {
375                success: true,
376                output: format!("Committed: {message}"),
377                error: None,
378            }),
379            Err(e) => Ok(ToolResult {
380                success: false,
381                output: String::new(),
382                error: Some(format!("Commit failed: {e}")),
383            }),
384        }
385    }
386
387    async fn git_add(
388        &self,
389        args: serde_json::Value,
390        working_dir: &std::path::Path,
391    ) -> anyhow::Result<ToolResult> {
392        let paths = args
393            .get("paths")
394            .and_then(|v| v.as_str())
395            .ok_or_else(|| anyhow::anyhow!("Missing 'paths' parameter"))?;
396
397        // Validate paths against injection patterns
398        self.sanitize_git_args(paths)?;
399
400        let output = self
401            .run_git_command(&["add", "--", paths], working_dir)
402            .await;
403
404        match output {
405            Ok(_) => Ok(ToolResult {
406                success: true,
407                output: format!("Staged: {paths}"),
408                error: None,
409            }),
410            Err(e) => Ok(ToolResult {
411                success: false,
412                output: String::new(),
413                error: Some(format!("Add failed: {e}")),
414            }),
415        }
416    }
417
418    async fn git_checkout(
419        &self,
420        args: serde_json::Value,
421        working_dir: &std::path::Path,
422    ) -> anyhow::Result<ToolResult> {
423        let branch = args
424            .get("branch")
425            .and_then(|v| v.as_str())
426            .ok_or_else(|| anyhow::anyhow!("Missing 'branch' parameter"))?;
427
428        // Sanitize branch name
429        let sanitized = self.sanitize_git_args(branch)?;
430
431        if sanitized.is_empty() || sanitized.len() > 1 {
432            anyhow::bail!("Invalid branch specification");
433        }
434
435        let branch_name = &sanitized[0];
436
437        // Block dangerous branch names
438        if branch_name.contains('@') || branch_name.contains('^') || branch_name.contains('~') {
439            anyhow::bail!("Branch name contains invalid characters");
440        }
441
442        let output = self
443            .run_git_command(&["checkout", branch_name], working_dir)
444            .await;
445
446        match output {
447            Ok(_) => Ok(ToolResult {
448                success: true,
449                output: format!("Switched to branch: {branch_name}"),
450                error: None,
451            }),
452            Err(e) => Ok(ToolResult {
453                success: false,
454                output: String::new(),
455                error: Some(format!("Checkout failed: {e}")),
456            }),
457        }
458    }
459
460    async fn git_stash(
461        &self,
462        args: serde_json::Value,
463        working_dir: &std::path::Path,
464    ) -> anyhow::Result<ToolResult> {
465        let action = args
466            .get("action")
467            .and_then(|v| v.as_str())
468            .unwrap_or("push");
469
470        let output = match action {
471            "push" | "save" => {
472                self.run_git_command(&["stash", "push", "-m", "auto-stash"], working_dir)
473                    .await
474            }
475            "pop" => self.run_git_command(&["stash", "pop"], working_dir).await,
476            "list" => self.run_git_command(&["stash", "list"], working_dir).await,
477            "drop" => {
478                let index_raw = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
479                let index = i32::try_from(index_raw)
480                    .map_err(|_| anyhow::anyhow!("stash index too large: {index_raw}"))?;
481                self.run_git_command(
482                    &["stash", "drop", &format!("stash@{{{index}}}")],
483                    working_dir,
484                )
485                .await
486            }
487            _ => anyhow::bail!("Unknown stash action: {action}. Use: push, pop, list, drop"),
488        };
489
490        match output {
491            Ok(out) => Ok(ToolResult {
492                success: true,
493                output: out,
494                error: None,
495            }),
496            Err(e) => Ok(ToolResult {
497                success: false,
498                output: String::new(),
499                error: Some(format!("Stash {action} failed: {e}")),
500            }),
501        }
502    }
503}
504
505#[async_trait]
506impl Tool for GitOperationsTool {
507    fn name(&self) -> &str {
508        "git_operations"
509    }
510
511    fn description(&self) -> &str {
512        "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls."
513    }
514
515    fn parameters_schema(&self) -> serde_json::Value {
516        json!({
517            "type": "object",
518            "properties": {
519                "operation": {
520                    "type": "string",
521                    "enum": ["status", "diff", "log", "branch", "commit", "add", "checkout", "stash"],
522                    "description": "Git operation to perform"
523                },
524                "message": {
525                    "type": "string",
526                    "description": "Commit message (for 'commit' operation)"
527                },
528                "paths": {
529                    "type": "string",
530                    "description": "File paths to stage (for 'add' operation)"
531                },
532                "branch": {
533                    "type": "string",
534                    "description": "Branch name (for 'checkout' operation)"
535                },
536                "files": {
537                    "type": "string",
538                    "description": "File or path to diff (for 'diff' operation, default: '.')"
539                },
540                "cached": {
541                    "type": "boolean",
542                    "description": "Show staged changes (for 'diff' operation)"
543                },
544                "limit": {
545                    "type": "integer",
546                    "description": "Number of log entries (for 'log' operation, default: 10)"
547                },
548                "action": {
549                    "type": "string",
550                    "enum": ["push", "pop", "list", "drop"],
551                    "description": "Stash action (for 'stash' operation)"
552                },
553                "index": {
554                    "type": "integer",
555                    "description": "Stash index (for 'stash' with 'drop' action)"
556                },
557                "path": {
558                    "type": "string",
559                    "description": "Optional subdirectory path within the workspace to run git operations in. Defaults to workspace root."
560                }
561            },
562            "required": ["operation"]
563        })
564    }
565
566    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
567        let operation = match args.get("operation").and_then(|v| v.as_str()) {
568            Some(op) => op,
569            None => {
570                return Ok(ToolResult {
571                    success: false,
572                    output: String::new(),
573                    error: Some("Missing 'operation' parameter".into()),
574                });
575            }
576        };
577
578        let path = args.get("path").and_then(|v| v.as_str());
579        let working_dir = match self.resolve_working_dir(path) {
580            Ok(d) => d,
581            Err(e) => {
582                return Ok(ToolResult {
583                    success: false,
584                    output: String::new(),
585                    error: Some(format!("Invalid path: {e}")),
586                });
587            }
588        };
589
590        // Check if we're in a git repository
591        if !working_dir.join(".git").exists() {
592            // Try to find .git in parent directories
593            let mut current_dir = working_dir.as_path();
594            let mut found_git = false;
595            while current_dir.parent().is_some() {
596                if current_dir.join(".git").exists() {
597                    found_git = true;
598                    break;
599                }
600                current_dir = current_dir.parent().unwrap();
601            }
602
603            if !found_git {
604                return Ok(ToolResult {
605                    success: false,
606                    output: String::new(),
607                    error: Some("Not in a git repository".into()),
608                });
609            }
610        }
611
612        // Check autonomy level for write operations
613        if self.requires_write_access(operation) {
614            if !self.security.can_act() {
615                return Ok(ToolResult {
616                    success: false,
617                    output: String::new(),
618                    error: Some(
619                        "Action blocked: git write operations require higher autonomy level".into(),
620                    ),
621                });
622            }
623
624            match self.security.autonomy {
625                AutonomyLevel::ReadOnly => {
626                    return Ok(ToolResult {
627                        success: false,
628                        output: String::new(),
629                        error: Some("Action blocked: read-only mode".into()),
630                    });
631                }
632                AutonomyLevel::Supervised | AutonomyLevel::Full => {}
633            }
634        }
635
636        // Record action for rate limiting
637        if !self.security.record_action() {
638            return Ok(ToolResult {
639                success: false,
640                output: String::new(),
641                error: Some("Action blocked: rate limit exceeded".into()),
642            });
643        }
644
645        // Execute the requested operation
646        match operation {
647            "status" => self.git_status(args, &working_dir).await,
648            "diff" => self.git_diff(args, &working_dir).await,
649            "log" => self.git_log(args, &working_dir).await,
650            "branch" => self.git_branch(args, &working_dir).await,
651            "commit" => self.git_commit(args, &working_dir).await,
652            "add" => self.git_add(args, &working_dir).await,
653            "checkout" => self.git_checkout(args, &working_dir).await,
654            "stash" => self.git_stash(args, &working_dir).await,
655            _ => Ok(ToolResult {
656                success: false,
657                output: String::new(),
658                error: Some(format!("Unknown operation: {operation}")),
659            }),
660        }
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::security::SecurityPolicy;
668    use tempfile::TempDir;
669
670    fn test_tool(dir: &std::path::Path) -> GitOperationsTool {
671        let security = Arc::new(SecurityPolicy {
672            autonomy: AutonomyLevel::Supervised,
673            ..SecurityPolicy::default()
674        });
675        GitOperationsTool::new(security, dir.to_path_buf())
676    }
677
678    #[test]
679    fn sanitize_git_blocks_injection() {
680        let tmp = TempDir::new().unwrap();
681        let tool = test_tool(tmp.path());
682
683        // Should block dangerous arguments
684        assert!(tool.sanitize_git_args("--exec=rm -rf /").is_err());
685        assert!(tool.sanitize_git_args("$(echo pwned)").is_err());
686        assert!(tool.sanitize_git_args("`malicious`").is_err());
687        assert!(tool.sanitize_git_args("arg | cat").is_err());
688        assert!(tool.sanitize_git_args("arg; rm file").is_err());
689    }
690
691    #[test]
692    fn sanitize_git_blocks_pager_editor_injection() {
693        let tmp = TempDir::new().unwrap();
694        let tool = test_tool(tmp.path());
695
696        assert!(tool.sanitize_git_args("--pager=less").is_err());
697        assert!(tool.sanitize_git_args("--editor=vim").is_err());
698    }
699
700    #[test]
701    fn sanitize_git_blocks_config_injection() {
702        let tmp = TempDir::new().unwrap();
703        let tool = test_tool(tmp.path());
704
705        // Exact `-c` flag (config injection)
706        assert!(tool.sanitize_git_args("-c core.sshCommand=evil").is_err());
707        assert!(tool.sanitize_git_args("-c=core.pager=less").is_err());
708    }
709
710    #[test]
711    fn sanitize_git_blocks_no_verify() {
712        let tmp = TempDir::new().unwrap();
713        let tool = test_tool(tmp.path());
714
715        assert!(tool.sanitize_git_args("--no-verify").is_err());
716    }
717
718    #[test]
719    fn sanitize_git_blocks_redirect_in_args() {
720        let tmp = TempDir::new().unwrap();
721        let tool = test_tool(tmp.path());
722
723        assert!(tool.sanitize_git_args("file.txt > /tmp/out").is_err());
724    }
725
726    #[test]
727    fn sanitize_git_cached_not_blocked() {
728        let tmp = TempDir::new().unwrap();
729        let tool = test_tool(tmp.path());
730
731        // --cached must NOT be blocked by the `-c` check
732        assert!(tool.sanitize_git_args("--cached").is_ok());
733        // Other safe flags starting with -c prefix
734        assert!(tool.sanitize_git_args("-cached").is_ok());
735    }
736
737    #[test]
738    fn sanitize_git_allows_safe() {
739        let tmp = TempDir::new().unwrap();
740        let tool = test_tool(tmp.path());
741
742        // Should allow safe arguments
743        assert!(tool.sanitize_git_args("main").is_ok());
744        assert!(tool.sanitize_git_args("feature/test-branch").is_ok());
745        assert!(tool.sanitize_git_args("--cached").is_ok());
746        assert!(tool.sanitize_git_args("src/main.rs").is_ok());
747        assert!(tool.sanitize_git_args(".").is_ok());
748    }
749
750    #[test]
751    fn requires_write_detection() {
752        let tmp = TempDir::new().unwrap();
753        let tool = test_tool(tmp.path());
754
755        assert!(tool.requires_write_access("commit"));
756        assert!(tool.requires_write_access("add"));
757        assert!(tool.requires_write_access("checkout"));
758
759        assert!(!tool.requires_write_access("status"));
760        assert!(!tool.requires_write_access("diff"));
761        assert!(!tool.requires_write_access("log"));
762    }
763
764    #[test]
765    fn branch_is_not_write_gated() {
766        let tmp = TempDir::new().unwrap();
767        let tool = test_tool(tmp.path());
768
769        // Branch listing is read-only; it must not require write access
770        assert!(!tool.requires_write_access("branch"));
771        assert!(tool.is_read_only("branch"));
772    }
773
774    #[test]
775    fn is_read_only_detection() {
776        let tmp = TempDir::new().unwrap();
777        let tool = test_tool(tmp.path());
778
779        assert!(tool.is_read_only("status"));
780        assert!(tool.is_read_only("diff"));
781        assert!(tool.is_read_only("log"));
782        assert!(tool.is_read_only("branch"));
783
784        assert!(!tool.is_read_only("commit"));
785        assert!(!tool.is_read_only("add"));
786    }
787
788    #[tokio::test]
789    async fn blocks_readonly_mode_for_write_ops() {
790        let tmp = TempDir::new().unwrap();
791        // Initialize a git repository
792        std::process::Command::new("git")
793            .args(["init"])
794            .current_dir(tmp.path())
795            .output()
796            .unwrap();
797
798        let security = Arc::new(SecurityPolicy {
799            autonomy: AutonomyLevel::ReadOnly,
800            ..SecurityPolicy::default()
801        });
802        let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
803
804        let result = tool
805            .execute(json!({"operation": "commit", "message": "test"}))
806            .await
807            .unwrap();
808        assert!(!result.success);
809        // can_act() returns false for ReadOnly, so we get the "higher autonomy level" message
810        assert!(
811            result
812                .error
813                .as_deref()
814                .unwrap_or("")
815                .contains("higher autonomy")
816        );
817    }
818
819    #[tokio::test]
820    async fn allows_branch_listing_in_readonly_mode() {
821        let tmp = TempDir::new().unwrap();
822        // Initialize a git repository so the command can succeed
823        std::process::Command::new("git")
824            .args(["init"])
825            .current_dir(tmp.path())
826            .output()
827            .unwrap();
828
829        let security = Arc::new(SecurityPolicy {
830            autonomy: AutonomyLevel::ReadOnly,
831            ..SecurityPolicy::default()
832        });
833        let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
834
835        let result = tool.execute(json!({"operation": "branch"})).await.unwrap();
836        // Branch listing must not be blocked by read-only autonomy
837        let error_msg = result.error.as_deref().unwrap_or("");
838        assert!(
839            !error_msg.contains("read-only") && !error_msg.contains("higher autonomy"),
840            "branch listing should not be blocked in read-only mode, got: {error_msg}"
841        );
842    }
843
844    #[tokio::test]
845    async fn allows_readonly_ops_in_readonly_mode() {
846        let tmp = TempDir::new().unwrap();
847        let security = Arc::new(SecurityPolicy {
848            autonomy: AutonomyLevel::ReadOnly,
849            ..SecurityPolicy::default()
850        });
851        let tool = GitOperationsTool::new(security, tmp.path().to_path_buf());
852
853        // This will fail because there's no git repo, but it shouldn't be blocked by autonomy
854        let result = tool.execute(json!({"operation": "status"})).await.unwrap();
855        // The error should be about git (not about autonomy/read-only mode)
856        assert!(!result.success, "Expected failure due to missing git repo");
857        let error_msg = result.error.as_deref().unwrap_or("");
858        assert!(
859            !error_msg.is_empty(),
860            "Expected a git-related error message"
861        );
862        assert!(
863            !error_msg.contains("read-only") && !error_msg.contains("autonomy"),
864            "Error should be about git, not about autonomy restrictions: {error_msg}"
865        );
866    }
867
868    #[tokio::test]
869    async fn rejects_missing_operation() {
870        let tmp = TempDir::new().unwrap();
871        let tool = test_tool(tmp.path());
872
873        let result = tool.execute(json!({})).await.unwrap();
874        assert!(!result.success);
875        assert!(
876            result
877                .error
878                .as_deref()
879                .unwrap_or("")
880                .contains("Missing 'operation'")
881        );
882    }
883
884    #[tokio::test]
885    async fn rejects_unknown_operation() {
886        let tmp = TempDir::new().unwrap();
887        // Initialize a git repository
888        std::process::Command::new("git")
889            .args(["init"])
890            .current_dir(tmp.path())
891            .output()
892            .unwrap();
893
894        let tool = test_tool(tmp.path());
895
896        let result = tool.execute(json!({"operation": "push"})).await.unwrap();
897        assert!(!result.success);
898        assert!(
899            result
900                .error
901                .as_deref()
902                .unwrap_or("")
903                .contains("Unknown operation")
904        );
905    }
906
907    #[test]
908    fn truncates_multibyte_commit_message_without_panicking() {
909        let long = "🦀".repeat(2500);
910        let truncated = GitOperationsTool::truncate_commit_message(&long);
911
912        assert_eq!(truncated.chars().count(), 2000);
913    }
914
915    #[test]
916    fn resolve_working_dir_none_returns_workspace() {
917        let tmp = TempDir::new().unwrap();
918        let tool = test_tool(tmp.path());
919
920        let result = tool.resolve_working_dir(None).unwrap();
921        assert_eq!(result, tmp.path().to_path_buf());
922    }
923
924    #[test]
925    fn resolve_working_dir_empty_returns_workspace() {
926        let tmp = TempDir::new().unwrap();
927        let tool = test_tool(tmp.path());
928
929        let result = tool.resolve_working_dir(Some("")).unwrap();
930        assert_eq!(result, tmp.path().to_path_buf());
931    }
932
933    #[test]
934    fn resolve_working_dir_valid_subdir() {
935        let tmp = TempDir::new().unwrap();
936        std::fs::create_dir(tmp.path().join("subproject")).unwrap();
937        let tool = test_tool(tmp.path());
938
939        let result = tool.resolve_working_dir(Some("subproject")).unwrap();
940        let expected = tmp.path().join("subproject").canonicalize().unwrap();
941        assert_eq!(result, expected);
942    }
943
944    #[test]
945    fn resolve_working_dir_rejects_traversal() {
946        let tmp = TempDir::new().unwrap();
947        let tool = test_tool(tmp.path());
948
949        let result = tool.resolve_working_dir(Some(".."));
950        assert!(result.is_err());
951        let err_msg = result.unwrap_err().to_string();
952        assert!(
953            err_msg.contains("resolves outside the workspace"),
954            "Expected traversal rejection, got: {err_msg}"
955        );
956    }
957
958    #[tokio::test]
959    async fn git_operations_work_in_subdirectory() {
960        let tmp = TempDir::new().unwrap();
961        let sub = tmp.path().join("nested");
962        std::fs::create_dir(&sub).unwrap();
963        std::process::Command::new("git")
964            .args(["init"])
965            .current_dir(&sub)
966            .output()
967            .unwrap();
968        std::process::Command::new("git")
969            .args(["config", "user.email", "test@test.com"])
970            .current_dir(&sub)
971            .output()
972            .unwrap();
973        std::process::Command::new("git")
974            .args(["config", "user.name", "Test"])
975            .current_dir(&sub)
976            .output()
977            .unwrap();
978
979        let tool = test_tool(tmp.path());
980
981        let result = tool
982            .execute(json!({"operation": "status", "path": "nested"}))
983            .await
984            .unwrap();
985        assert!(
986            result.success,
987            "Expected success, got error: {:?}",
988            result.error
989        );
990        assert!(result.output.contains("branch"));
991    }
992}