1use 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
25pub struct GitTool;
27
28impl GitTool {
29 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];
45const NET_ACTIONS: &[&str] = &["push", "pull", "fetch"];
47
48fn 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 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 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 "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 "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); }
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 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 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
270fn 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 assert!(!WRITE_ACTIONS.contains(&"reset"));
309 assert!(!WRITE_ACTIONS.contains(&"clean"));
310 }
311}