Skip to main content

atman_runtime/tools/
git_branch.rs

1use std::path::PathBuf;
2
3use git2::{BranchType, Repository};
4
5use crate::error::RuntimeError;
6use crate::git::{GitCli, has_changes};
7use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
8use crate::value::Value;
9
10pub struct GitBranchList;
11pub struct GitBranchCreate;
12pub struct GitBranchSwitch;
13pub struct GitBranchRename;
14pub struct GitBranchDelete;
15pub struct GitRemoteList;
16
17fn cwd(args: &ToolArgs, ctx: &ToolCtx) -> Result<PathBuf, RuntimeError> {
18    let explicit = args.named("cwd").and_then(|value| match value {
19        Value::Str(path) => Some(std::path::Path::new(path)),
20        _ => None,
21    });
22    ctx.resolve_cwd(explicit)
23}
24
25fn string_arg<'a>(args: &'a ToolArgs, name: &str) -> Result<&'a str, RuntimeError> {
26    match args.named(name) {
27        Some(Value::Str(value)) => Ok(value),
28        Some(other) => Err(RuntimeError::TypeMismatch {
29            expected: "string".into(),
30            actual: other.kind_name().into(),
31        }),
32        None => Err(RuntimeError::MissingArg(name.into())),
33    }
34}
35
36fn optional_string<'a>(args: &'a ToolArgs, name: &str) -> Result<Option<&'a str>, RuntimeError> {
37    match args.named(name) {
38        Some(Value::Str(value)) => Ok(Some(value)),
39        Some(other) => Err(RuntimeError::TypeMismatch {
40            expected: "string".into(),
41            actual: other.kind_name().into(),
42        }),
43        None => Ok(None),
44    }
45}
46
47fn bool_arg(args: &ToolArgs, name: &str, default: bool) -> Result<bool, RuntimeError> {
48    match args.named(name) {
49        Some(Value::Bool(value)) => Ok(*value),
50        Some(other) => Err(RuntimeError::TypeMismatch {
51            expected: "boolean".into(),
52            actual: other.kind_name().into(),
53        }),
54        None => Ok(default),
55    }
56}
57
58fn repo(args: &ToolArgs, ctx: &ToolCtx, tool: &str) -> Result<(PathBuf, Repository), RuntimeError> {
59    let cwd = cwd(args, ctx)?;
60    let repository = Repository::open(&cwd)
61        .map_err(|error| RuntimeError::ToolFailed(format!("{tool}: {error}")))?;
62    Ok((cwd, repository))
63}
64
65async fn repo_mut(
66    args: &ToolArgs,
67    ctx: &ToolCtx,
68    tool: &str,
69) -> Result<(PathBuf, Repository), RuntimeError> {
70    let cwd = cwd(args, ctx)?;
71    crate::fs_access::authorize_write(ctx, &cwd, tool, true).await?;
72    let repository = Repository::open(&cwd)
73        .map_err(|error| RuntimeError::ToolFailed(format!("{tool}: {error}")))?;
74    Ok((cwd, repository))
75}
76
77fn result(tool: &str, error: impl std::fmt::Display) -> RuntimeError {
78    RuntimeError::ToolFailed(format!("{tool}: {error}"))
79}
80
81fn branch_value(name: String, branch: &git2::Branch<'_>, current: bool) -> Value {
82    let reference = branch.get();
83    let commit = reference.target().map(|oid| oid.to_string());
84    let upstream = branch
85        .upstream()
86        .ok()
87        .and_then(|upstream| upstream.name().ok().flatten().map(str::to_owned));
88    Value::Struct(vec![
89        ("name".into(), Value::Str(name)),
90        ("sha".into(), commit.map(Value::Str).unwrap_or(Value::Unit)),
91        ("current".into(), Value::Bool(current)),
92        (
93            "upstream".into(),
94            upstream.map(Value::Str).unwrap_or(Value::Unit),
95        ),
96    ])
97}
98
99impl Tool for GitBranchList {
100    fn name(&self) -> &str {
101        "git.branch.list"
102    }
103    fn tier(&self) -> Tier {
104        Tier::Zero
105    }
106    fn description(&self) -> Option<&str> {
107        Some("List local branches and their current/upstream state.")
108    }
109    fn input_schema(&self) -> serde_json::Value {
110        serde_json::json!({"type":"object","properties":{"cwd":{"type":"string"}}})
111    }
112    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
113        Box::pin(async move {
114            let (_, repository) = repo(&args, ctx, self.name())?;
115            let current = repository
116                .head()
117                .ok()
118                .and_then(|head| head.shorthand().map(str::to_owned));
119            let mut values = Vec::new();
120            for item in repository
121                .branches(Some(BranchType::Local))
122                .map_err(|e| result(self.name(), e))?
123            {
124                let (branch, _) = item.map_err(|e| result(self.name(), e))?;
125                let name = branch
126                    .name()
127                    .map_err(|e| result(self.name(), e))?
128                    .unwrap_or_default()
129                    .to_owned();
130                values.push(branch_value(
131                    name.clone(),
132                    &branch,
133                    current.as_deref() == Some(name.as_str()),
134                ));
135            }
136            Ok(Value::List(values))
137        })
138    }
139}
140
141impl Tool for GitBranchCreate {
142    fn name(&self) -> &str {
143        "git.branch.create"
144    }
145    fn tier(&self) -> Tier {
146        Tier::Two
147    }
148    fn description(&self) -> Option<&str> {
149        Some("Create a local branch without checking it out.")
150    }
151    fn input_schema(&self) -> serde_json::Value {
152        serde_json::json!({"type":"object","required":["name"],"properties":{"name":{"type":"string"},"start":{"type":"string"},"cwd":{"type":"string"}}})
153    }
154    fn invocation_provenance(
155        &self,
156        args: &ToolArgs,
157        ctx: &ToolCtx,
158    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
159        crate::tools::git_ops::git_mutation_provenance(args, ctx)
160    }
161
162    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
163        Box::pin(async move {
164            let (_, repository) = repo_mut(&args, ctx, self.name()).await?;
165            let name = string_arg(&args, "name")?;
166            let start = optional_string(&args, "start")?;
167            let commit = match start {
168                Some(revision) => repository
169                    .revparse_single(revision)
170                    .and_then(|object| object.peel_to_commit()),
171                None => repository.head().and_then(|head| head.peel_to_commit()),
172            }
173            .map_err(|e| result(self.name(), e))?;
174            repository
175                .branch(name, &commit, false)
176                .map_err(|e| result(self.name(), e))?;
177            Ok(Value::Struct(vec![
178                ("name".into(), Value::Str(name.into())),
179                ("sha".into(), Value::Str(commit.id().to_string())),
180            ]))
181        })
182    }
183}
184
185impl Tool for GitBranchSwitch {
186    fn name(&self) -> &str {
187        "git.branch.switch"
188    }
189    fn tier(&self) -> Tier {
190        Tier::Two
191    }
192    fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
193        match bool_arg(args, "force", false) {
194            Ok(true) => ApprovalLevel::Dangerous,
195            _ => ApprovalLevel::Approve,
196        }
197    }
198    fn description(&self) -> Option<&str> {
199        Some("Switch branches after refusing a dirty worktree unless force=true.")
200    }
201    fn input_schema(&self) -> serde_json::Value {
202        serde_json::json!({"type":"object","required":["name"],"properties":{"name":{"type":"string"},"create":{"type":"boolean"},"start":{"type":"string"},"force":{"type":"boolean"},"cwd":{"type":"string"}}})
203    }
204    fn invocation_provenance(
205        &self,
206        args: &ToolArgs,
207        ctx: &ToolCtx,
208    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
209        crate::tools::git_ops::git_mutation_provenance(args, ctx)
210    }
211
212    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
213        Box::pin(async move {
214            let (cwd, repository) = repo_mut(&args, ctx, self.name()).await?;
215            let name = string_arg(&args, "name")?;
216            let create = bool_arg(&args, "create", false)?;
217            let force = bool_arg(&args, "force", false)?;
218            if !force && has_changes(&cwd).map_err(|e| result(self.name(), e))? {
219                return Err(result(self.name(), "dirty worktree requires force=true"));
220            }
221            if !create {
222                repository
223                    .find_branch(name, BranchType::Local)
224                    .map_err(|e| result(self.name(), e))?;
225            }
226            GitCli::at(&cwd)
227                .switch_branch(name, create, optional_string(&args, "start")?)
228                .map_err(|e| result(self.name(), e))?;
229            Ok(Value::Struct(vec![
230                ("name".into(), Value::Str(name.into())),
231                ("created".into(), Value::Bool(create)),
232            ]))
233        })
234    }
235}
236
237impl Tool for GitBranchRename {
238    fn name(&self) -> &str {
239        "git.branch.rename"
240    }
241    fn tier(&self) -> Tier {
242        Tier::Two
243    }
244    fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
245        ApprovalLevel::Approve
246    }
247    fn description(&self) -> Option<&str> {
248        Some("Rename a local branch.")
249    }
250    fn input_schema(&self) -> serde_json::Value {
251        serde_json::json!({"type":"object","required":["old","new"],"properties":{"old":{"type":"string"},"new":{"type":"string"},"cwd":{"type":"string"}}})
252    }
253    fn invocation_provenance(
254        &self,
255        args: &ToolArgs,
256        ctx: &ToolCtx,
257    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
258        crate::tools::git_ops::git_mutation_provenance(args, ctx)
259    }
260
261    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
262        Box::pin(async move {
263            let (_, repository) = repo_mut(&args, ctx, self.name()).await?;
264            let old = string_arg(&args, "old")?;
265            let new = string_arg(&args, "new")?;
266            let mut branch = repository
267                .find_branch(old, BranchType::Local)
268                .map_err(|e| result(self.name(), e))?;
269            branch
270                .rename(new, false)
271                .map_err(|e| result(self.name(), e))?;
272            Ok(Value::Struct(vec![
273                ("old".into(), Value::Str(old.into())),
274                ("new".into(), Value::Str(new.into())),
275            ]))
276        })
277    }
278}
279
280impl Tool for GitBranchDelete {
281    fn name(&self) -> &str {
282        "git.branch.delete"
283    }
284    fn tier(&self) -> Tier {
285        Tier::Three
286    }
287    fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
288        ApprovalLevel::Dangerous
289    }
290    fn description(&self) -> Option<&str> {
291        Some("Delete a local branch; current branches are refused.")
292    }
293    fn input_schema(&self) -> serde_json::Value {
294        serde_json::json!({"type":"object","required":["name"],"properties":{"name":{"type":"string"},"force":{"type":"boolean"},"cwd":{"type":"string"}}})
295    }
296    fn invocation_provenance(
297        &self,
298        args: &ToolArgs,
299        ctx: &ToolCtx,
300    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
301        crate::tools::git_ops::git_mutation_provenance(args, ctx)
302    }
303
304    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
305        Box::pin(async move {
306            let (_, repository) = repo_mut(&args, ctx, self.name()).await?;
307            let name = string_arg(&args, "name")?;
308            if repository
309                .head()
310                .ok()
311                .and_then(|head| head.shorthand().map(str::to_owned))
312                .as_deref()
313                == Some(name)
314            {
315                return Err(result(self.name(), "cannot delete current branch"));
316            }
317            let mut branch = repository
318                .find_branch(name, BranchType::Local)
319                .map_err(|e| result(self.name(), e))?;
320            let force = bool_arg(&args, "force", false)?;
321            if force {
322                branch.delete().map_err(|e| result(self.name(), e))?;
323            } else {
324                branch.delete().map_err(|e| result(self.name(), e))?;
325            }
326            Ok(Value::Struct(vec![
327                ("name".into(), Value::Str(name.into())),
328                ("deleted".into(), Value::Bool(true)),
329            ]))
330        })
331    }
332}
333
334impl Tool for GitRemoteList {
335    fn name(&self) -> &str {
336        "git.remote.list"
337    }
338    fn tier(&self) -> Tier {
339        Tier::Zero
340    }
341    fn description(&self) -> Option<&str> {
342        Some("List configured Git remotes and fetch/push URLs.")
343    }
344    fn input_schema(&self) -> serde_json::Value {
345        serde_json::json!({"type":"object","properties":{"cwd":{"type":"string"}}})
346    }
347    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
348        Box::pin(async move {
349            let (_, repository) = repo(&args, ctx, self.name())?;
350            let mut values = Vec::new();
351            for name in repository
352                .remotes()
353                .map_err(|e| result(self.name(), e))?
354                .iter()
355                .flatten()
356            {
357                let remote = repository
358                    .find_remote(name)
359                    .map_err(|e| result(self.name(), e))?;
360                values.push(Value::Struct(vec![
361                    ("name".into(), Value::Str(name.to_string())),
362                    (
363                        "url".into(),
364                        remote
365                            .url()
366                            .map(str::to_owned)
367                            .map(Value::Str)
368                            .unwrap_or(Value::Unit),
369                    ),
370                    (
371                        "push_url".into(),
372                        remote
373                            .pushurl()
374                            .map(str::to_owned)
375                            .map(Value::Str)
376                            .unwrap_or(Value::Unit),
377                    ),
378                ]));
379            }
380            Ok(Value::List(values))
381        })
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use std::process::Command;
389
390    fn run(dir: &std::path::Path, args: &[&str]) -> String {
391        let output = Command::new("git")
392            .args(args)
393            .current_dir(dir)
394            .output()
395            .unwrap();
396        assert!(
397            output.status.success(),
398            "git {} failed: {}",
399            args.join(" "),
400            String::from_utf8_lossy(&output.stderr)
401        );
402        String::from_utf8_lossy(&output.stdout).trim().to_owned()
403    }
404
405    fn repo() -> tempfile::TempDir {
406        let dir = tempfile::tempdir().unwrap();
407        run(dir.path(), &["init", "-q", "-b", "main"]);
408        run(dir.path(), &["config", "user.name", "test"]);
409        run(dir.path(), &["config", "user.email", "test@example.com"]);
410        std::fs::write(dir.path().join("a.txt"), "one\n").unwrap();
411        run(dir.path(), &["add", "."]);
412        run(dir.path(), &["commit", "-qm", "initial"]);
413        dir
414    }
415
416    #[test]
417    fn branch_lifecycle_and_dirty_switch_safety() {
418        let dir = repo();
419        let args = ToolArgs {
420            named: vec![
421                ("cwd".into(), Value::Str(dir.path().display().to_string())),
422                ("name".into(), Value::Str("feature".into())),
423            ],
424            ..ToolArgs::default()
425        };
426        let ctx = ToolCtx::default();
427        futures::executor::block_on(GitBranchCreate.call(args, &ctx)).unwrap();
428        assert!(
429            run(dir.path(), &["show-ref", "--verify", "refs/heads/feature"]).contains("feature")
430        );
431        let switch_args = ToolArgs {
432            named: vec![
433                ("cwd".into(), Value::Str(dir.path().display().to_string())),
434                ("name".into(), Value::Str("feature".into())),
435            ],
436            ..ToolArgs::default()
437        };
438        futures::executor::block_on(GitBranchSwitch.call(switch_args, &ctx)).unwrap();
439        assert_eq!(run(dir.path(), &["branch", "--show-current"]), "feature");
440        std::fs::write(dir.path().join("dirty"), "x").unwrap();
441        let back_args = ToolArgs {
442            named: vec![
443                ("cwd".into(), Value::Str(dir.path().display().to_string())),
444                ("name".into(), Value::Str("main".into())),
445            ],
446            ..ToolArgs::default()
447        };
448        assert!(futures::executor::block_on(GitBranchSwitch.call(back_args, &ctx)).is_err());
449        let rename_args = ToolArgs {
450            named: vec![
451                ("cwd".into(), Value::Str(dir.path().display().to_string())),
452                ("old".into(), Value::Str("feature".into())),
453                ("new".into(), Value::Str("renamed".into())),
454            ],
455            ..ToolArgs::default()
456        };
457        futures::executor::block_on(GitBranchRename.call(rename_args, &ctx)).unwrap();
458        let delete_args = ToolArgs {
459            named: vec![
460                ("cwd".into(), Value::Str(dir.path().display().to_string())),
461                ("name".into(), Value::Str("renamed".into())),
462            ],
463            ..ToolArgs::default()
464        };
465        assert!(futures::executor::block_on(GitBranchDelete.call(delete_args, &ctx)).is_err());
466    }
467
468    #[test]
469    fn force_switch_requires_dangerous_approval() {
470        let tool = GitBranchSwitch;
471        let ctx = ToolCtx::default();
472        assert_eq!(
473            tool.approval_level(&ToolArgs::default(), &ctx),
474            ApprovalLevel::Approve
475        );
476        let args = ToolArgs {
477            named: vec![("force".into(), Value::Bool(true))],
478            ..ToolArgs::default()
479        };
480        assert_eq!(tool.approval_level(&args, &ctx), ApprovalLevel::Dangerous);
481    }
482
483    #[test]
484    fn branch_list_and_remote_list_return_structured_values() {
485        let dir = repo();
486        run(
487            dir.path(),
488            &[
489                "remote",
490                "add",
491                "origin",
492                "https://example.invalid/repo.git",
493            ],
494        );
495        let ctx = ToolCtx::default();
496        let args = ToolArgs {
497            named: vec![("cwd".into(), Value::Str(dir.path().display().to_string()))],
498            ..ToolArgs::default()
499        };
500        let branches = futures::executor::block_on(GitBranchList.call(args.clone(), &ctx)).unwrap();
501        assert!(matches!(branches, Value::List(items) if !items.is_empty()));
502        let remotes = futures::executor::block_on(GitRemoteList.call(args, &ctx)).unwrap();
503        assert!(matches!(remotes, Value::List(items) if items.len() == 1));
504    }
505}