Skip to main content

aegis_tools/
git.rs

1//! # GitTool (read + write)
2//!
3//! A scoped git tool covering the software-engineering workflow: inspection
4//! (status/log/diff/show/branch/blame) **and** management
5//! (add/commit/checkout/merge/push/pull/fetch/tag/stash).
6//!
7//! Write actions execute **autonomously** (no per-action approval) by explicit
8//! user choice — aegis manages git on its own. Safety is preserved structurally:
9//! an argv array (no shell → no injection) and validated refs/remotes/paths.
10//! Truly destructive history rewrites (`reset --hard`, `clean -fd`) are
11//! deliberately NOT exposed here — those stay on the `terminal` path, which
12//! warns before running them.
13//!
14//! Interim backend: the `git` CLI (zero new deps, mirrors `service`). Per the
15//! Rust-first sourcing principle (docs/aegis-tool-capability-gaps.md §B), `gix`
16//! is the intended pure-Rust backend once it can be compile-verified.
17
18use crate::registry::{Tool, ToolContext};
19use aegis_security::check_path;
20use anyhow::Result;
21use async_trait::async_trait;
22use serde_json::{json, Value};
23use std::process::Stdio;
24
25/// Read + write git tool (autonomous writes, no approval).
26pub struct GitTool;
27
28impl GitTool {
29    /// Create a new `GitTool`.
30    pub fn new() -> Self {
31        GitTool
32    }
33}
34
35impl Default for GitTool {
36    fn default() -> Self {
37        Self::new()
38    }
39}
40
41const READ_ACTIONS: &[&str] = &["status", "log", "diff", "show", "branch", "blame"];
42const WRITE_ACTIONS: &[&str] = &[
43    "add", "commit", "checkout", "merge", "push", "pull", "fetch", "tag", "stash",
44];
45/// Actions that hit the network and thus get a longer timeout.
46const NET_ACTIONS: &[&str] = &["push", "pull", "fetch"];
47
48/// A git ref/remote/branch is safe if non-empty, whitespace-free, doesn't start
49/// with '-' (could be read as a flag), and uses only ref-legal characters.
50fn valid_ref(r: &str) -> bool {
51    !r.is_empty()
52        && r.len() <= 200
53        && !r.starts_with('-')
54        && !r.contains(char::is_whitespace)
55        && r.chars().all(|c| {
56            c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '_' | '-' | '~' | '^' | '@' | '{' | '}' | ':')
57        })
58}
59
60#[async_trait]
61impl Tool for GitTool {
62    fn name(&self) -> &str {
63        "git"
64    }
65
66    fn description(&self) -> &str {
67        "Run git for the full dev workflow. Read: status, log, diff, show, branch, blame. \
68         Write (runs autonomously): add, commit (needs `message`), checkout (`ref`, `create` for -b), \
69         merge (`ref`), push (`remote`/`branch`/`set_upstream`), pull, fetch, tag (`name`), stash (`sub`=push|pop|list). \
70         Destructive history rewrites (reset --hard, clean) are intentionally not here — use `terminal` for those."
71    }
72
73    fn parameters(&self) -> Value {
74        json!({
75            "type": "object",
76            "properties": {
77                "action": {
78                    "type": "string",
79                    "enum": ["status", "log", "diff", "show", "branch", "blame",
80                             "add", "commit", "checkout", "merge", "push", "pull", "fetch", "tag", "stash"],
81                    "description": "git action to run"
82                },
83                "path": { "type": "string", "description": "File to scope to (log/diff/blame/add). Within the working directory." },
84                "ref": { "type": "string", "description": "A commit/branch/tag ref (show/checkout/merge; also log/diff start)" },
85                "message": { "type": "string", "description": "Commit or annotated-tag message (commit/tag)" },
86                "name": { "type": "string", "description": "Tag name (tag)" },
87                "remote": { "type": "string", "description": "Remote name for push/pull/fetch (default: git's default)" },
88                "branch": { "type": "string", "description": "Branch name for push/pull" },
89                "create": { "type": "boolean", "description": "checkout: create the branch (-b)" },
90                "all": { "type": "boolean", "description": "add: stage everything (-A); commit: stage tracked changes (-a)" },
91                "set_upstream": { "type": "boolean", "description": "push: set upstream (-u)" },
92                "staged": { "type": "boolean", "description": "diff: show staged changes" },
93                "sub": { "type": "string", "enum": ["push", "pop", "list"], "description": "stash subcommand (default push)" },
94                "limit": { "type": "integer", "description": "log: number of commits (default 20)" }
95            },
96            "required": ["action"]
97        })
98    }
99
100    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
101        let action = args["action"].as_str().unwrap_or("").trim();
102        let is_read = READ_ACTIONS.contains(&action);
103        let is_write = WRITE_ACTIONS.contains(&action);
104        if !is_read && !is_write {
105            return Ok(format!(
106                "Error: unknown action '{action}'. Read: {}. Write: {}.",
107                READ_ACTIONS.join("/"),
108                WRITE_ACTIONS.join("/")
109            ));
110        }
111
112        // Validate optional ref/remote/branch (argv-safe, but reject flag-like).
113        let git_ref = opt_str(&args, "ref");
114        let remote = opt_str(&args, "remote");
115        let branch = opt_str(&args, "branch");
116        for (label, v) in [("ref", &git_ref), ("remote", &remote), ("branch", &branch)] {
117            if let Some(x) = v {
118                if !valid_ref(x) {
119                    return Ok(format!("Error: invalid {label} '{x}' (no spaces, no leading '-', ref-legal chars only)"));
120                }
121            }
122        }
123        // Validate optional path (confined to the working directory).
124        let rel_path = match opt_str(&args, "path") {
125            Some(p) => {
126                check_path(&p, &ctx.cwd)?;
127                Some(p)
128            }
129            None => None,
130        };
131
132        let mut a: Vec<String> = vec!["--no-pager".into()];
133        match action {
134            // ── read ──
135            "status" => a.extend(["status".into(), "--short".into(), "--branch".into()]),
136            "log" => {
137                let limit = args["limit"].as_u64().unwrap_or(20).min(500);
138                a.extend(["log".into(), "--oneline".into(), "--decorate".into(), "-n".into(), limit.to_string()]);
139                if let Some(r) = &git_ref { a.push(r.clone()); }
140                if let Some(p) = &rel_path { a.push("--".into()); a.push(p.clone()); }
141            }
142            "diff" => {
143                a.extend(["diff".into(), "--stat".into()]);
144                if args["staged"].as_bool().unwrap_or(false) { a.push("--staged".into()); }
145                if let Some(r) = &git_ref { a.push(r.clone()); }
146                if let Some(p) = &rel_path { a.push("--".into()); a.push(p.clone()); }
147            }
148            "show" => a.extend(["show".into(), "--stat".into(), git_ref.clone().unwrap_or_else(|| "HEAD".into())]),
149            "branch" => a.extend(["branch".into(), "-vv".into(), "--no-color".into()]),
150            "blame" => {
151                let Some(p) = &rel_path else { return Ok("Error: 'blame' requires a 'path'".into()); };
152                a.extend(["blame".into(), "--date=short".into(), "-w".into(), "--".into(), p.clone()]);
153            }
154            // ── write (autonomous) ──
155            "add" => {
156                a.push("add".into());
157                if let Some(p) = &rel_path {
158                    a.push("--".into());
159                    a.push(p.clone());
160                } else if args["all"].as_bool().unwrap_or(false) {
161                    a.push("-A".into());
162                } else {
163                    return Ok("Error: 'add' needs a 'path' or all=true".into());
164                }
165            }
166            "commit" => {
167                let Some(msg) = opt_str(&args, "message") else {
168                    return Ok("Error: 'commit' requires a 'message'".into());
169                };
170                a.push("commit".into());
171                if args["all"].as_bool().unwrap_or(false) { a.push("-a".into()); }
172                a.push("-m".into());
173                a.push(msg); // argv value — safe even if it starts with '-'
174            }
175            "checkout" => {
176                let Some(r) = &git_ref else { return Ok("Error: 'checkout' requires a 'ref'".into()); };
177                a.push("checkout".into());
178                if args["create"].as_bool().unwrap_or(false) { a.push("-b".into()); }
179                a.push(r.clone());
180            }
181            "merge" => {
182                let Some(r) = &git_ref else { return Ok("Error: 'merge' requires a 'ref'".into()); };
183                a.extend(["merge".into(), "--no-edit".into(), r.clone()]);
184            }
185            "push" => {
186                a.push("push".into());
187                if args["set_upstream"].as_bool().unwrap_or(false) { a.push("-u".into()); }
188                if let Some(r) = &remote { a.push(r.clone()); }
189                if let Some(b) = &branch { a.push(b.clone()); }
190            }
191            "pull" => {
192                a.push("pull".into());
193                if let Some(r) = &remote { a.push(r.clone()); }
194                if let Some(b) = &branch { a.push(b.clone()); }
195            }
196            "fetch" => {
197                a.push("fetch".into());
198                if let Some(r) = &remote { a.push(r.clone()); }
199            }
200            "tag" => {
201                let Some(name) = opt_str(&args, "name") else {
202                    return Ok("Error: 'tag' requires a 'name'".into());
203                };
204                if !valid_ref(&name) {
205                    return Ok(format!("Error: invalid tag name '{name}'"));
206                }
207                if let Some(msg) = opt_str(&args, "message") {
208                    a.extend(["tag".into(), "-a".into(), name, "-m".into(), msg]);
209                } else {
210                    a.extend(["tag".into(), name]);
211                }
212            }
213            "stash" => {
214                let sub = opt_str(&args, "sub").unwrap_or_else(|| "push".into());
215                if !["push", "pop", "list"].contains(&sub.as_str()) {
216                    return Ok("Error: stash 'sub' must be push, pop or list".into());
217                }
218                a.extend(["stash".into(), sub]);
219            }
220            _ => unreachable!(),
221        }
222
223        let timeout_secs = if NET_ACTIONS.contains(&action) { 120 } else { 60 };
224        let output = tokio::time::timeout(
225            std::time::Duration::from_secs(timeout_secs),
226            tokio::process::Command::new("git")
227                .args(&a)
228                .current_dir(&ctx.cwd)
229                .stdout(Stdio::piped())
230                .stderr(Stdio::piped())
231                .output(),
232        )
233        .await
234        .map_err(|_| anyhow::anyhow!("git {action} timed out after {timeout_secs}s"))?;
235
236        let output = match output {
237            Ok(o) => o,
238            Err(e) => return Ok(format!("Failed to run git: {e}. Is git installed and on PATH?")),
239        };
240
241        let stdout = String::from_utf8_lossy(&output.stdout);
242        let stderr = String::from_utf8_lossy(&output.stderr);
243        let code = output.status.code().unwrap_or(-1);
244
245        if !output.status.success() {
246            // Surface stderr (git writes its progress/errors there) with any stdout.
247            let mut msg = format!("git {action} failed (exit {code})");
248            if !stderr.trim().is_empty() { msg.push_str(&format!(":\n{}", stderr.trim())); }
249            if !stdout.trim().is_empty() { msg.push_str(&format!("\n{}", stdout.trim())); }
250            if is_write && action == "merge" {
251                msg.push_str("\n(If this is a merge conflict, resolve it via `terminal`/edits, then commit.)");
252            }
253            return Ok(truncate(&msg, 20_000));
254        }
255
256        // Success: prefer stdout; many write ops report on stderr instead.
257        let mut result = String::new();
258        if !stdout.trim().is_empty() { result.push_str(stdout.trim_end()); }
259        if !stderr.trim().is_empty() {
260            if !result.is_empty() { result.push('\n'); }
261            result.push_str(stderr.trim_end());
262        }
263        if result.is_empty() {
264            result = format!("git {action}: done.");
265        }
266        Ok(truncate(&result, 20_000))
267    }
268}
269
270/// Read a non-empty trimmed string arg.
271fn opt_str(args: &Value, key: &str) -> Option<String> {
272    args[key].as_str().map(|s| s.trim()).filter(|s| !s.is_empty()).map(|s| s.to_string())
273}
274
275fn truncate(s: &str, max: usize) -> String {
276    if s.len() <= max {
277        s.to_string()
278    } else {
279        let cut: String = s.chars().take(max).collect();
280        format!("{cut}\n... [truncated]")
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn valid_ref_accepts_and_rejects() {
290        assert!(valid_ref("HEAD"));
291        assert!(valid_ref("HEAD~3"));
292        assert!(valid_ref("origin/main"));
293        assert!(valid_ref("v1.2.3"));
294        assert!(valid_ref("feature/foo_bar"));
295        assert!(!valid_ref(""));
296        assert!(!valid_ref("--upload-pack=evil"));
297        assert!(!valid_ref("a b"));
298        assert!(!valid_ref("$(whoami)"));
299        assert!(!valid_ref("a;b"));
300    }
301
302    #[test]
303    fn action_sets_cover_workflow() {
304        assert!(READ_ACTIONS.contains(&"status"));
305        assert!(WRITE_ACTIONS.contains(&"commit"));
306        assert!(WRITE_ACTIONS.contains(&"push"));
307        // destructive rewrites are deliberately absent
308        assert!(!WRITE_ACTIONS.contains(&"reset"));
309        assert!(!WRITE_ACTIONS.contains(&"clean"));
310    }
311}