Skip to main content

atman_runtime/tools/
git_worktree.rs

1use std::path::PathBuf;
2
3use crate::error::RuntimeError;
4use crate::git::{GitCli, WorktreeInfo};
5use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8pub struct GitWorktreeAdd;
9pub struct GitWorktreeList;
10pub struct GitWorktreeRemove;
11pub struct GitWorktreePrune;
12pub struct GitWorktreeLock;
13pub struct GitWorktreeUnlock;
14
15fn string_value(value: &Value) -> Option<&str> {
16    match value {
17        Value::Str(value) => Some(value),
18        _ => None,
19    }
20}
21
22fn bool_value(value: &Value) -> Option<bool> {
23    match value {
24        Value::Bool(value) => Some(*value),
25        _ => None,
26    }
27}
28
29fn cwd(args: &ToolArgs, ctx: &ToolCtx) -> Result<PathBuf, RuntimeError> {
30    let explicit = args.named("cwd").and_then(|value| match value {
31        Value::Str(path) => Some(std::path::Path::new(path)),
32        _ => None,
33    });
34    ctx.resolve_cwd(explicit)
35}
36
37async fn cwd_mut(args: &ToolArgs, ctx: &ToolCtx, tool: &str) -> Result<PathBuf, RuntimeError> {
38    let cwd = cwd(args, ctx)?;
39    crate::fs_access::authorize_write(ctx, &cwd, tool, true).await?;
40    Ok(cwd)
41}
42
43fn string_arg<'a>(args: &'a ToolArgs, key: &str) -> Result<&'a str, RuntimeError> {
44    args.named(key)
45        .and_then(string_value)
46        .ok_or_else(|| RuntimeError::MissingArg(key.into()))
47}
48
49fn worktree_path(args: &ToolArgs, ctx: &ToolCtx) -> Result<PathBuf, RuntimeError> {
50    ctx.resolve_path(std::path::Path::new(string_arg(args, "path")?))
51}
52
53async fn mutation_paths(
54    args: &ToolArgs,
55    ctx: &ToolCtx,
56    tool: &str,
57) -> Result<(PathBuf, PathBuf), RuntimeError> {
58    let cwd = cwd_mut(args, ctx, tool).await?;
59    let path = worktree_path(args, ctx)?;
60    crate::fs_access::authorize_write(ctx, &path, tool, true).await?;
61    Ok((cwd, path))
62}
63
64fn registered_worktree_path(cwd: &std::path::Path, requested: &std::path::Path) -> PathBuf {
65    GitCli::at(cwd)
66        .worktree_list()
67        .ok()
68        .and_then(|entries| {
69            entries.into_iter().find_map(|entry| {
70                (crate::fs_access::canonicalize_stable(&entry.path) == requested)
71                    .then_some(entry.path)
72            })
73        })
74        .unwrap_or_else(|| requested.to_path_buf())
75}
76
77fn mutation_provenance(
78    args: &ToolArgs,
79    ctx: &ToolCtx,
80    registered: bool,
81) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
82    let cwd = cwd(args, ctx)?;
83    let requested = worktree_path(args, ctx)?;
84    let path = if registered {
85        registered_worktree_path(&cwd, &requested)
86    } else {
87        requested
88    };
89    Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
90        .with_cwd(
91            ctx,
92            args.named("cwd")
93                .and_then(string_value)
94                .map(std::path::Path::new),
95        )?
96        .with_extra_target(ctx, &path)?
97        .with_risk(crate::trust::RiskKind::RepositoryMutation))
98}
99
100fn bool_arg(args: &ToolArgs, key: &str, default: bool) -> bool {
101    args.named(key).and_then(bool_value).unwrap_or(default)
102}
103
104fn failure(tool: &str, error: impl std::fmt::Display) -> RuntimeError {
105    RuntimeError::ToolFailed(format!("{tool}: {error}"))
106}
107
108fn entry_value(entry: WorktreeInfo) -> Value {
109    Value::Struct(vec![
110        ("path".into(), Value::Str(entry.path.display().to_string())),
111        (
112            "head".into(),
113            entry.head.map(Value::Str).unwrap_or(Value::Unit),
114        ),
115        (
116            "branch".into(),
117            entry.branch.map(Value::Str).unwrap_or(Value::Unit),
118        ),
119        ("detached".into(), Value::Bool(entry.detached)),
120        ("bare".into(), Value::Bool(entry.bare)),
121        (
122            "locked".into(),
123            entry.locked.map(Value::Str).unwrap_or(Value::Unit),
124        ),
125        (
126            "prunable".into(),
127            entry.prunable.map(Value::Str).unwrap_or(Value::Unit),
128        ),
129    ])
130}
131
132impl Tool for GitWorktreeList {
133    fn name(&self) -> &str {
134        "git.worktree.list"
135    }
136    fn tier(&self) -> Tier {
137        Tier::Zero
138    }
139    fn description(&self) -> Option<&str> {
140        Some("List repository worktrees with attached, detached, locked, and prunable state.")
141    }
142    fn input_schema(&self) -> serde_json::Value {
143        serde_json::json!({"type":"object","properties":{"cwd":{"type":"string","description":"Optional repository working directory."}}})
144    }
145    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
146        Box::pin(async move {
147            let entries = GitCli::at(cwd(&args, ctx)?)
148                .worktree_list()
149                .map_err(|e| failure(self.name(), e))?;
150            Ok(Value::List(entries.into_iter().map(entry_value).collect()))
151        })
152    }
153}
154
155impl Tool for GitWorktreeAdd {
156    fn name(&self) -> &str {
157        "git.worktree.add"
158    }
159    fn tier(&self) -> Tier {
160        Tier::Two
161    }
162    fn description(&self) -> Option<&str> {
163        Some("Add a linked Git worktree after validating path and branch conflicts.")
164    }
165    fn input_schema(&self) -> serde_json::Value {
166        serde_json::json!({
167            "type":"object",
168            "properties":{
169                "path":{"type":"string"}, "branch":{"type":"string"}, "base":{"type":"string"},
170                "create_branch":{"type":"boolean","default":false}, "detach":{"type":"boolean","default":false},
171                "cwd":{"type":"string","description":"Optional repository working directory."}
172            },
173            "required":["path"]
174        })
175    }
176    fn invocation_provenance(
177        &self,
178        args: &ToolArgs,
179        ctx: &ToolCtx,
180    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
181        mutation_provenance(args, ctx, false)
182    }
183
184    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
185        Box::pin(async move {
186            let (cwd, path) = mutation_paths(&args, ctx, self.name()).await?;
187            let entry = GitCli::at(cwd)
188                .worktree_add(
189                    &path,
190                    args.named("branch").and_then(string_value),
191                    args.named("base").and_then(string_value),
192                    bool_arg(&args, "create_branch", false),
193                    bool_arg(&args, "detach", false),
194                )
195                .map_err(|e| failure(self.name(), e))?;
196            Ok(entry_value(entry))
197        })
198    }
199}
200
201impl Tool for GitWorktreeRemove {
202    fn name(&self) -> &str {
203        "git.worktree.remove"
204    }
205    fn tier(&self) -> Tier {
206        Tier::Two
207    }
208    fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
209        if bool_arg(args, "force", false) {
210            ApprovalLevel::Dangerous
211        } else {
212            ApprovalLevel::Approve
213        }
214    }
215    fn description(&self) -> Option<&str> {
216        Some("Remove a registered linked worktree; dirty worktrees require force=true.")
217    }
218    fn input_schema(&self) -> serde_json::Value {
219        serde_json::json!({"type":"object","properties":{"path":{"type":"string"},"force":{"type":"boolean","default":false},"cwd":{"type":"string"}},"required":["path"]})
220    }
221    fn invocation_provenance(
222        &self,
223        args: &ToolArgs,
224        ctx: &ToolCtx,
225    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
226        mutation_provenance(args, ctx, true)
227    }
228
229    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
230        Box::pin(async move {
231            let cwd = cwd_mut(&args, ctx, self.name()).await?;
232            let requested = worktree_path(&args, ctx)?;
233            let path = registered_worktree_path(&cwd, &requested);
234            crate::fs_access::authorize_write(ctx, &path, self.name(), true).await?;
235            GitCli::at(cwd)
236                .worktree_remove(&path, bool_arg(&args, "force", false))
237                .map_err(|e| failure(self.name(), e))?;
238            Ok(Value::Struct(vec![
239                ("path".into(), Value::Str(path.display().to_string())),
240                ("removed".into(), Value::Bool(true)),
241            ]))
242        })
243    }
244}
245
246impl Tool for GitWorktreePrune {
247    fn name(&self) -> &str {
248        "git.worktree.prune"
249    }
250    fn tier(&self) -> Tier {
251        Tier::Three
252    }
253    fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
254        if bool_arg(args, "dry_run", true) {
255            ApprovalLevel::Auto
256        } else {
257            ApprovalLevel::Dangerous
258        }
259    }
260    fn description(&self) -> Option<&str> {
261        Some("Inspect or prune stale worktree metadata; dry-run is the default.")
262    }
263    fn input_schema(&self) -> serde_json::Value {
264        serde_json::json!({"type":"object","properties":{"cwd":{"type":"string"},"dry_run":{"type":"boolean","default":true}}})
265    }
266    fn invocation_provenance(
267        &self,
268        args: &ToolArgs,
269        ctx: &ToolCtx,
270    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
271        if bool_arg(args, "dry_run", true) {
272            return Ok(crate::permission::ResourceProvenance::for_ctx(ctx));
273        }
274        Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
275            .with_cwd(
276                ctx,
277                args.named("cwd")
278                    .and_then(string_value)
279                    .map(std::path::Path::new),
280            )?
281            .with_risk(crate::trust::RiskKind::RepositoryMutation))
282    }
283
284    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
285        Box::pin(async move {
286            let dry_run = bool_arg(&args, "dry_run", true);
287            let cwd = if dry_run {
288                cwd(&args, ctx)?
289            } else {
290                cwd_mut(&args, ctx, self.name()).await?
291            };
292            let output = GitCli::at(cwd)
293                .worktree_prune(dry_run)
294                .map_err(|e| failure(self.name(), e))?;
295            Ok(Value::Struct(vec![
296                ("dry_run".into(), Value::Bool(dry_run)),
297                ("output".into(), Value::Str(output)),
298            ]))
299        })
300    }
301}
302
303impl Tool for GitWorktreeLock {
304    fn name(&self) -> &str {
305        "git.worktree.lock"
306    }
307    fn tier(&self) -> Tier {
308        Tier::Two
309    }
310    fn description(&self) -> Option<&str> {
311        Some("Lock a registered linked worktree, optionally recording a reason.")
312    }
313    fn input_schema(&self) -> serde_json::Value {
314        serde_json::json!({"type":"object","properties":{"path":{"type":"string"},"reason":{"type":"string"},"cwd":{"type":"string"}},"required":["path"]})
315    }
316    fn invocation_provenance(
317        &self,
318        args: &ToolArgs,
319        ctx: &ToolCtx,
320    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
321        mutation_provenance(args, ctx, false)
322    }
323
324    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
325        Box::pin(async move {
326            let (cwd, path) = mutation_paths(&args, ctx, self.name()).await?;
327            GitCli::at(cwd)
328                .worktree_lock(&path, args.named("reason").and_then(string_value))
329                .map_err(|e| failure(self.name(), e))?;
330            Ok(Value::Struct(vec![
331                ("path".into(), Value::Str(path.display().to_string())),
332                ("locked".into(), Value::Bool(true)),
333            ]))
334        })
335    }
336}
337
338impl Tool for GitWorktreeUnlock {
339    fn name(&self) -> &str {
340        "git.worktree.unlock"
341    }
342    fn tier(&self) -> Tier {
343        Tier::Two
344    }
345    fn description(&self) -> Option<&str> {
346        Some("Unlock a registered linked worktree.")
347    }
348    fn input_schema(&self) -> serde_json::Value {
349        serde_json::json!({"type":"object","properties":{"path":{"type":"string"},"cwd":{"type":"string"}},"required":["path"]})
350    }
351    fn invocation_provenance(
352        &self,
353        args: &ToolArgs,
354        ctx: &ToolCtx,
355    ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
356        mutation_provenance(args, ctx, false)
357    }
358
359    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
360        Box::pin(async move {
361            let (cwd, path) = mutation_paths(&args, ctx, self.name()).await?;
362            GitCli::at(cwd)
363                .worktree_unlock(&path)
364                .map_err(|e| failure(self.name(), e))?;
365            Ok(Value::Struct(vec![
366                ("path".into(), Value::Str(path.display().to_string())),
367                ("locked".into(), Value::Bool(false)),
368            ]))
369        })
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use std::path::{Path, PathBuf};
377    use std::process::Command;
378
379    struct ExternalPath(PathBuf);
380
381    impl ExternalPath {
382        fn new(label: &str) -> Self {
383            let path = Path::new(env!("CARGO_MANIFEST_DIR"))
384                .parent()
385                .unwrap()
386                .join(format!("atman-r4-{label}-{}", uuid::Uuid::now_v7()));
387            assert!(!path.exists());
388            Self(path)
389        }
390    }
391
392    impl Drop for ExternalPath {
393        fn drop(&mut self) {
394            let _ = std::fs::remove_dir_all(&self.0);
395        }
396    }
397
398    fn git(cwd: &Path, args: &[&str]) -> String {
399        let output = Command::new("git")
400            .args(args)
401            .current_dir(cwd)
402            .output()
403            .unwrap();
404        assert!(
405            output.status.success(),
406            "git {} failed: {}",
407            args.join(" "),
408            String::from_utf8_lossy(&output.stderr)
409        );
410        String::from_utf8_lossy(&output.stdout).trim().to_owned()
411    }
412
413    fn init_repo() -> tempfile::TempDir {
414        let repo = tempfile::tempdir().unwrap();
415        git(repo.path(), &["init", "-q"]);
416        git(
417            repo.path(),
418            &["config", "user.email", "atman@example.invalid"],
419        );
420        git(repo.path(), &["config", "user.name", "Atman Test"]);
421        std::fs::write(repo.path().join("README.md"), "seed\n").unwrap();
422        git(repo.path(), &["add", "README.md"]);
423        git(repo.path(), &["commit", "-qm", "seed"]);
424        repo
425    }
426
427    fn managed_ctx(repo: &Path) -> ToolCtx {
428        ToolCtx::new()
429            .with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
430                repo.to_path_buf(),
431            ))
432            .with_workspace(crate::git_workspace::WorkspaceBinding {
433                workspace_id: "test".into(),
434                repository_root: repo.to_path_buf(),
435                path: repo.to_path_buf(),
436                branch: None,
437            })
438    }
439
440    fn worktree_args(path: impl Into<String>) -> ToolArgs {
441        ToolArgs {
442            named: vec![
443                ("path".into(), Value::Str(path.into())),
444                ("detach".into(), Value::Bool(true)),
445            ],
446            ..ToolArgs::default()
447        }
448    }
449
450    fn registered_paths(repo: &Path) -> Vec<PathBuf> {
451        GitCli::at(repo)
452            .worktree_list()
453            .unwrap()
454            .into_iter()
455            .map(|entry| crate::fs_access::canonicalize_stable(&entry.path))
456            .collect()
457    }
458
459    fn refs(repo: &Path) -> String {
460        git(repo, &["show-ref"])
461    }
462
463    fn args(key: &str, value: bool) -> ToolArgs {
464        ToolArgs {
465            named: vec![(key.into(), Value::Bool(value))],
466            ..ToolArgs::default()
467        }
468    }
469
470    #[test]
471    fn worktree_path_resolves_relative_to_context_cwd() {
472        let workspace = tempfile::tempdir().unwrap();
473        let ctx = ToolCtx::new().with_workspace(crate::git_workspace::WorkspaceBinding {
474            workspace_id: "test".into(),
475            repository_root: workspace.path().to_path_buf(),
476            path: workspace.path().to_path_buf(),
477            branch: None,
478        });
479        let args = ToolArgs {
480            named: vec![("path".into(), Value::Str("linked".into()))],
481            ..ToolArgs::default()
482        };
483
484        assert_eq!(
485            worktree_path(&args, &ctx).unwrap(),
486            workspace.path().canonicalize().unwrap().join("linked")
487        );
488    }
489
490    #[tokio::test]
491    async fn add_relative_path_creates_worktree_under_managed_root() {
492        let root = tempfile::tempdir().unwrap();
493        let repo = root.path().join("repo");
494        std::fs::create_dir(&repo).unwrap();
495        git(&repo, &["init", "-q"]);
496        git(&repo, &["config", "user.email", "atman@example.invalid"]);
497        git(&repo, &["config", "user.name", "Atman Test"]);
498        std::fs::write(repo.join("README.md"), "seed\n").unwrap();
499        git(&repo, &["add", "README.md"]);
500        git(&repo, &["commit", "-qm", "seed"]);
501        let ctx = managed_ctx(root.path());
502        let mut args = worktree_args("linked");
503        args.named.push(("cwd".into(), Value::Str("repo".into())));
504
505        GitWorktreeAdd.call(args, &ctx).await.unwrap();
506
507        let expected = root.path().join("linked").canonicalize().unwrap();
508        assert!(expected.exists());
509        assert!(registered_paths(&repo).contains(&expected));
510        GitCli::at(&repo).worktree_remove(&expected, true).unwrap();
511    }
512
513    #[tokio::test]
514    async fn add_external_path_rejection_preserves_directory_registration_and_refs() {
515        let repo = init_repo();
516        let target = ExternalPath::new("add-deny");
517        let ctx = managed_ctx(repo.path());
518        let registrations_before = registered_paths(repo.path());
519        let refs_before = refs(repo.path());
520
521        let error = GitWorktreeAdd
522            .call(worktree_args(target.0.to_string_lossy().into_owned()), &ctx)
523            .await
524            .unwrap_err();
525
526        assert!(error.to_string().contains("outside workspace"));
527        assert!(!target.0.exists());
528        assert_eq!(registered_paths(repo.path()), registrations_before);
529        assert_eq!(refs(repo.path()), refs_before);
530    }
531
532    #[tokio::test]
533    async fn add_external_temp_path_is_allowed() {
534        let repo = init_repo();
535        let parent = tempfile::tempdir().unwrap();
536        let target = parent.path().join("linked");
537        let ctx = managed_ctx(repo.path());
538
539        GitWorktreeAdd
540            .call(worktree_args(target.to_string_lossy().into_owned()), &ctx)
541            .await
542            .unwrap();
543
544        let expected = target.canonicalize().unwrap();
545        assert!(registered_paths(repo.path()).contains(&expected));
546        GitCli::at(repo.path())
547            .worktree_remove(&expected, true)
548            .unwrap();
549    }
550
551    #[tokio::test]
552    async fn add_external_path_is_allowed_with_full_access() {
553        let repo = init_repo();
554        let target = ExternalPath::new("add-full");
555        let ctx = managed_ctx(repo.path())
556            .with_fs_access(crate::fs_access::FsAccessPolicy::danger_full_access());
557
558        GitWorktreeAdd
559            .call(worktree_args(target.0.to_string_lossy().into_owned()), &ctx)
560            .await
561            .unwrap();
562
563        let expected = target.0.canonicalize().unwrap();
564        assert!(registered_paths(repo.path()).contains(&expected));
565        GitCli::at(repo.path())
566            .worktree_remove(&expected, true)
567            .unwrap();
568    }
569
570    #[tokio::test]
571    async fn remove_external_rejection_preserves_directory_and_registration() {
572        let repo = init_repo();
573        let target = ExternalPath::new("remove-deny");
574        GitCli::at(repo.path())
575            .worktree_add(&target.0, None, None, false, true)
576            .unwrap();
577        let canonical = target.0.canonicalize().unwrap();
578        let registrations_before = registered_paths(repo.path());
579        let ctx = managed_ctx(repo.path());
580        let args = ToolArgs {
581            named: vec![(
582                "path".into(),
583                Value::Str(target.0.to_string_lossy().into_owned()),
584            )],
585            ..ToolArgs::default()
586        };
587
588        let error = GitWorktreeRemove.call(args, &ctx).await.unwrap_err();
589
590        assert!(error.to_string().contains("outside workspace"));
591        assert!(canonical.exists());
592        assert_eq!(registered_paths(repo.path()), registrations_before);
593        GitCli::at(repo.path())
594            .worktree_remove(&canonical, true)
595            .unwrap();
596    }
597
598    #[test]
599    fn remove_force_escalates_approval() {
600        let tool = GitWorktreeRemove;
601        let ctx = ToolCtx::default();
602        assert_eq!(
603            tool.approval_level(&ToolArgs::default(), &ctx),
604            ApprovalLevel::Approve
605        );
606        assert_eq!(
607            tool.approval_level(&args("force", true), &ctx),
608            ApprovalLevel::Dangerous
609        );
610    }
611
612    #[test]
613    fn prune_defaults_to_dry_run_and_escalates_mutation() {
614        let tool = GitWorktreePrune;
615        let ctx = ToolCtx::default();
616        assert_eq!(
617            tool.approval_level(&ToolArgs::default(), &ctx),
618            ApprovalLevel::Auto
619        );
620        assert_eq!(
621            tool.approval_level(&args("dry_run", false), &ctx),
622            ApprovalLevel::Dangerous
623        );
624    }
625}