Skip to main content

atman_runtime/tools/
git_history.rs

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}