1use std::path::PathBuf;
2
3use crate::error::RuntimeError;
4use crate::git::GitCli;
5use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8pub struct GitRestore;
9pub struct GitRevert;
10pub struct GitTagList;
11pub struct GitTagCreate;
12
13fn cwd(args: &ToolArgs, ctx: &ToolCtx) -> Result<PathBuf, RuntimeError> {
14 let explicit = args.named("cwd").and_then(|value| match value {
15 Value::Str(path) => Some(std::path::Path::new(path)),
16 _ => None,
17 });
18 ctx.resolve_cwd(explicit)
19}
20
21async fn cwd_mut(args: &ToolArgs, ctx: &ToolCtx, tool: &str) -> Result<PathBuf, RuntimeError> {
22 let cwd = cwd(args, ctx)?;
23 crate::fs_access::authorize_write(ctx, &cwd, tool, true).await?;
24 Ok(cwd)
25}
26
27fn string_arg<'a>(args: &'a ToolArgs, name: &str) -> Result<&'a str, RuntimeError> {
28 match args.named(name) {
29 Some(Value::Str(value)) => Ok(value),
30 Some(other) => Err(RuntimeError::TypeMismatch {
31 expected: "string".into(),
32 actual: other.kind_name().into(),
33 }),
34 None => Err(RuntimeError::MissingArg(name.into())),
35 }
36}
37fn paths(args: &ToolArgs) -> Result<Vec<String>, RuntimeError> {
38 match args.named("paths") {
39 Some(Value::List(values)) => values
40 .iter()
41 .map(|value| match value {
42 Value::Str(path) if !path.is_empty() && !path.starts_with('-') => Ok(path.clone()),
43 Value::Str(_) => Err(RuntimeError::ToolFailed("git.restore: invalid path".into())),
44 other => Err(RuntimeError::TypeMismatch {
45 expected: "list<string>".into(),
46 actual: other.kind_name().into(),
47 }),
48 })
49 .collect(),
50 Some(other) => Err(RuntimeError::TypeMismatch {
51 expected: "list<string>".into(),
52 actual: other.kind_name().into(),
53 }),
54 None => Err(RuntimeError::MissingArg("paths".into())),
55 }
56}
57
58impl Tool for GitRestore {
59 fn name(&self) -> &str {
60 "git.restore"
61 }
62 fn tier(&self) -> Tier {
63 Tier::Two
64 }
65 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
66 ApprovalLevel::Approve
67 }
68 fn description(&self) -> Option<&str> {
69 Some(
70 "Restore explicit paths from HEAD or the index without allowing arbitrary reset/clean operations.",
71 )
72 }
73 fn input_schema(&self) -> serde_json::Value {
74 serde_json::json!({"type":"object","required":["paths"],"properties":{"paths":{"type":"array","items":{"type":"string"}},"mode":{"type":"string","enum":["worktree","staged","both"],"default":"worktree"},"source":{"type":"string"},"cwd":{"type":"string"}}})
75 }
76 fn invocation_provenance(
77 &self,
78 args: &ToolArgs,
79 ctx: &ToolCtx,
80 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
81 crate::tools::git_ops::git_mutation_provenance(args, ctx)
82 }
83
84 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
85 Box::pin(async move {
86 let cwd = cwd_mut(&args, ctx, self.name()).await?;
87 let values = paths(&args)?;
88 let mode = match args.named("mode") {
89 Some(Value::Str(mode)) => mode.as_str(),
90 Some(other) => {
91 return Err(RuntimeError::TypeMismatch {
92 expected: "string".into(),
93 actual: other.kind_name().into(),
94 });
95 }
96 None => "worktree",
97 };
98 if !matches!(mode, "worktree" | "staged" | "both") {
99 return Err(RuntimeError::ToolFailed(
100 "git.restore: mode must be worktree, staged, or both".into(),
101 ));
102 }
103 let source = match args.named("source") {
104 Some(Value::Str(source)) => source.as_str(),
105 Some(other) => {
106 return Err(RuntimeError::TypeMismatch {
107 expected: "string".into(),
108 actual: other.kind_name().into(),
109 });
110 }
111 None => "HEAD",
112 };
113 let cli = GitCli::at(&cwd);
114 let mut command = vec!["restore"];
115 if mode == "staged" || mode == "both" {
116 command.push("--staged");
117 }
118 if mode == "worktree" || mode == "both" {
119 command.push("--worktree");
120 }
121 command.push("--source");
122 command.push(source);
123 command.push("--");
124 let refs: Vec<&str> = values.iter().map(String::as_str).collect();
125 command.extend(refs);
126 cli.run(&command)
127 .map_err(|e| RuntimeError::ToolFailed(format!("git.restore: {e}")))?;
128 Ok(Value::Struct(vec![
129 ("mode".into(), Value::Str(mode.into())),
130 (
131 "paths".into(),
132 Value::List(values.into_iter().map(Value::Str).collect()),
133 ),
134 ]))
135 })
136 }
137}
138
139impl Tool for GitRevert {
140 fn name(&self) -> &str {
141 "git.revert"
142 }
143 fn tier(&self) -> Tier {
144 Tier::Three
145 }
146 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
147 ApprovalLevel::Dangerous
148 }
149 fn description(&self) -> Option<&str> {
150 Some("Create a signed/hook-aware revert commit for an explicit revision.")
151 }
152 fn input_schema(&self) -> serde_json::Value {
153 serde_json::json!({"type":"object","required":["revision"],"properties":{"revision":{"type":"string"},"cwd":{"type":"string"}}})
154 }
155 fn invocation_provenance(
156 &self,
157 args: &ToolArgs,
158 ctx: &ToolCtx,
159 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
160 crate::tools::git_ops::git_mutation_provenance(args, ctx)
161 }
162
163 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
164 Box::pin(async move {
165 let revision = string_arg(&args, "revision")?;
166 if revision.is_empty() || revision.starts_with('-') || revision.contains(' ') {
167 return Err(RuntimeError::ToolFailed(
168 "git.revert: invalid revision".into(),
169 ));
170 }
171 GitCli::at(cwd_mut(&args, ctx, self.name()).await?)
172 .run(&["revert", "--no-edit", revision])
173 .map_err(|e| RuntimeError::ToolFailed(format!("git.revert: {e}")))?;
174 Ok(Value::Struct(vec![(
175 "revision".into(),
176 Value::Str(revision.into()),
177 )]))
178 })
179 }
180}
181
182impl Tool for GitTagList {
183 fn name(&self) -> &str {
184 "git.tag.list"
185 }
186 fn tier(&self) -> Tier {
187 Tier::Zero
188 }
189 fn description(&self) -> Option<&str> {
190 Some("List local Git tags.")
191 }
192 fn input_schema(&self) -> serde_json::Value {
193 serde_json::json!({"type":"object","properties":{"cwd":{"type":"string"}}})
194 }
195 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
196 Box::pin(async move {
197 let output = GitCli::at(cwd(&args, ctx)?)
198 .run(&["tag", "--list", "--format=%(refname:short)"])
199 .map_err(|e| RuntimeError::ToolFailed(format!("git.tag.list: {e}")))?;
200 Ok(Value::List(
201 output
202 .lines()
203 .filter(|line| !line.is_empty())
204 .map(|line| Value::Str(line.to_owned()))
205 .collect(),
206 ))
207 })
208 }
209}
210
211impl Tool for GitTagCreate {
212 fn name(&self) -> &str {
213 "git.tag.create"
214 }
215 fn tier(&self) -> Tier {
216 Tier::Three
217 }
218 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
219 ApprovalLevel::Dangerous
220 }
221 fn description(&self) -> Option<&str> {
222 Some("Create a local tag for an explicit revision.")
223 }
224 fn input_schema(&self) -> serde_json::Value {
225 serde_json::json!({"type":"object","required":["name"],"properties":{"name":{"type":"string"},"revision":{"type":"string","default":"HEAD"},"cwd":{"type":"string"}}})
226 }
227 fn invocation_provenance(
228 &self,
229 args: &ToolArgs,
230 ctx: &ToolCtx,
231 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
232 crate::tools::git_ops::git_mutation_provenance(args, ctx)
233 }
234
235 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
236 Box::pin(async move {
237 let name = string_arg(&args, "name")?;
238 let revision = match args.named("revision") {
239 Some(Value::Str(value)) => value.as_str(),
240 Some(other) => {
241 return Err(RuntimeError::TypeMismatch {
242 expected: "string".into(),
243 actual: other.kind_name().into(),
244 });
245 }
246 None => "HEAD",
247 };
248 if name.is_empty()
249 || name.starts_with('-')
250 || name.contains("..")
251 || name.contains(' ')
252 || revision.starts_with('-')
253 {
254 return Err(RuntimeError::ToolFailed(
255 "git.tag.create: invalid tag or revision".into(),
256 ));
257 }
258 GitCli::at(cwd_mut(&args, ctx, self.name()).await?)
259 .run(&["tag", name, revision])
260 .map_err(|e| RuntimeError::ToolFailed(format!("git.tag.create: {e}")))?;
261 Ok(Value::Struct(vec![
262 ("name".into(), Value::Str(name.into())),
263 ("revision".into(), Value::Str(revision.into())),
264 ]))
265 })
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use std::process::Command;
273
274 fn run(dir: &std::path::Path, args: &[&str]) -> String {
275 let output = Command::new("git")
276 .args(args)
277 .current_dir(dir)
278 .output()
279 .unwrap();
280 assert!(
281 output.status.success(),
282 "git {} failed: {}",
283 args.join(" "),
284 String::from_utf8_lossy(&output.stderr)
285 );
286 String::from_utf8_lossy(&output.stdout).trim().to_owned()
287 }
288
289 fn repo() -> tempfile::TempDir {
290 let dir = tempfile::tempdir().unwrap();
291 run(dir.path(), &["init", "-q", "-b", "main"]);
292 run(dir.path(), &["config", "user.name", "test"]);
293 run(dir.path(), &["config", "user.email", "test@example.com"]);
294 run(dir.path(), &["config", "commit.gpgsign", "false"]);
295 std::fs::write(dir.path().join("a.txt"), "one\n").unwrap();
296 run(dir.path(), &["add", "."]);
297 run(dir.path(), &["commit", "-qm", "initial"]);
298 dir
299 }
300
301 #[tokio::test]
302 async fn restore_tag_and_revert_use_explicit_safe_inputs() {
303 let dir = repo();
304 std::fs::write(dir.path().join("a.txt"), "changed\n").unwrap();
305 let ctx = ToolCtx::default();
306 let restore_args = ToolArgs {
307 named: vec![
308 ("cwd".into(), Value::Str(dir.path().display().to_string())),
309 (
310 "paths".into(),
311 Value::List(vec![Value::Str("a.txt".into())]),
312 ),
313 ],
314 ..ToolArgs::default()
315 };
316 GitRestore.call(restore_args, &ctx).await.unwrap();
317 assert_eq!(
318 std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
319 "one\n"
320 );
321 let tag_args = ToolArgs {
322 named: vec![
323 ("cwd".into(), Value::Str(dir.path().display().to_string())),
324 ("name".into(), Value::Str("v1".into())),
325 ],
326 ..ToolArgs::default()
327 };
328 GitTagCreate.call(tag_args, &ctx).await.unwrap();
329 let tags = GitTagList
330 .call(
331 ToolArgs {
332 named: vec![("cwd".into(), Value::Str(dir.path().display().to_string()))],
333 ..ToolArgs::default()
334 },
335 &ctx,
336 )
337 .await
338 .unwrap();
339 assert!(
340 matches!(tags, Value::List(items) if items.iter().any(|item| matches!(item, Value::Str(value) if value == "v1")))
341 );
342 std::fs::write(dir.path().join("b.txt"), "two\n").unwrap();
343 run(dir.path(), &["add", "b.txt"]);
344 run(dir.path(), &["commit", "-qm", "second"]);
345 let revision = run(dir.path(), &["rev-parse", "HEAD"]);
346 GitRevert
347 .call(
348 ToolArgs {
349 named: vec![
350 ("cwd".into(), Value::Str(dir.path().display().to_string())),
351 ("revision".into(), Value::Str(revision)),
352 ],
353 ..ToolArgs::default()
354 },
355 &ctx,
356 )
357 .await
358 .unwrap();
359 assert!(!dir.path().join("b.txt").exists());
360 }
361
362 #[test]
363 fn high_risk_operations_require_dangerous_approval() {
364 let ctx = ToolCtx::default();
365 assert_eq!(
366 GitRevert.approval_level(&ToolArgs::default(), &ctx),
367 ApprovalLevel::Dangerous
368 );
369 assert_eq!(
370 GitTagCreate.approval_level(&ToolArgs::default(), &ctx),
371 ApprovalLevel::Dangerous
372 );
373 }
374}