Skip to main content

atman_runtime/tools/
git_workspace.rs

1use std::path::PathBuf;
2
3use crate::error::RuntimeError;
4use crate::git_workspace::{WorkspaceError, WorkspaceManager, WorkspaceRecord};
5use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8pub struct GitWorkspaceCreate;
9pub struct GitWorkspaceList;
10pub struct GitWorkspaceGet;
11pub struct GitWorkspaceRelease;
12pub struct GitWorkspaceRetain;
13pub struct GitWorkspacePrune;
14
15fn string_value(value: &Value) -> Option<&str> {
16    match value {
17        Value::Str(value) => Some(value),
18        _ => None,
19    }
20}
21fn bool_value(value: &Value) -> Option<bool> {
22    match value {
23        Value::Bool(value) => Some(*value),
24        _ => None,
25    }
26}
27fn cwd(args: &ToolArgs, ctx: &ToolCtx) -> Result<PathBuf, RuntimeError> {
28    let explicit = args
29        .named("cwd")
30        .and_then(string_value)
31        .map(std::path::Path::new);
32    ctx.resolve_cwd(explicit)
33}
34fn optional_string<'a>(args: &'a ToolArgs, key: &str) -> Option<&'a str> {
35    args.named(key).and_then(string_value)
36}
37fn required_string<'a>(args: &'a ToolArgs, key: &str) -> Result<&'a str, RuntimeError> {
38    optional_string(args, key).ok_or_else(|| RuntimeError::MissingArg(key.into()))
39}
40fn bool_arg(args: &ToolArgs, key: &str, default: bool) -> bool {
41    args.named(key).and_then(bool_value).unwrap_or(default)
42}
43struct ManagerPaths {
44    cwd: PathBuf,
45    external_root: Option<PathBuf>,
46}
47
48fn manager_paths(args: &ToolArgs, ctx: &ToolCtx) -> Result<ManagerPaths, RuntimeError> {
49    Ok(ManagerPaths {
50        cwd: cwd(args, ctx)?,
51        external_root: optional_string(args, "external_root")
52            .map(|root| ctx.resolve_path(std::path::Path::new(root)))
53            .transpose()?,
54    })
55}
56
57fn manager(paths: &ManagerPaths) -> Result<WorkspaceManager, RuntimeError> {
58    WorkspaceManager::at(&paths.cwd, paths.external_root.as_deref())
59        .map_err(|e| failure("git.workspace", e))
60}
61
62fn existing_manager(paths: &ManagerPaths) -> Result<Option<WorkspaceManager>, RuntimeError> {
63    WorkspaceManager::open_existing(&paths.cwd, paths.external_root.as_deref())
64        .map_err(|e| failure("git.workspace", e))
65}
66fn failure(tool: &str, error: impl std::fmt::Display) -> RuntimeError {
67    RuntimeError::ToolFailed(format!("{tool}: {error}"))
68}
69
70async fn authorize_mutation(
71    name: &str,
72    args: &ToolArgs,
73    ctx: &ToolCtx,
74    paths: &ManagerPaths,
75) -> Result<(), RuntimeError> {
76    let mutates = match name {
77        "git.workspace.create" | "git.workspace.release" | "git.workspace.retain" => true,
78        "git.workspace.prune" => !bool_arg(args, "dry_run", true),
79        _ => false,
80    };
81    if !mutates {
82        return Ok(());
83    }
84    crate::fs_access::authorize_write(ctx, &paths.cwd, name, true).await?;
85    if let Some(root) = &paths.external_root {
86        crate::fs_access::authorize_write(ctx, root, name, true).await?;
87    }
88    Ok(())
89}
90
91fn record_value(item: WorkspaceRecord) -> Value {
92    Value::Struct(vec![
93        ("id".into(), Value::Str(item.id)),
94        (
95            "repository_root".into(),
96            Value::Str(item.repository_root.display().to_string()),
97        ),
98        (
99            "worktree_path".into(),
100            Value::Str(item.worktree_path.display().to_string()),
101        ),
102        (
103            "branch".into(),
104            item.branch.map(Value::Str).unwrap_or(Value::Unit),
105        ),
106        (
107            "owner_session".into(),
108            item.owner_session.map(Value::Str).unwrap_or(Value::Unit),
109        ),
110        (
111            "owner_flow".into(),
112            item.owner_flow.map(Value::Str).unwrap_or(Value::Unit),
113        ),
114        ("state".into(), Value::Str(item.state)),
115        ("retained".into(), Value::Bool(item.retained)),
116    ])
117}
118
119macro_rules! basic_tool {
120    ($ty:ident, $name:literal, $tier:expr, $approval:expr, $desc:literal, $schema:expr, $body:expr) => {
121        impl Tool for $ty {
122            fn name(&self) -> &str {
123                $name
124            }
125            fn tier(&self) -> Tier {
126                $tier
127            }
128            fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
129                $approval
130            }
131            fn description(&self) -> Option<&str> {
132                Some($desc)
133            }
134            fn input_schema(&self) -> serde_json::Value {
135                $schema
136            }
137            fn invocation_provenance(
138                &self,
139                args: &ToolArgs,
140                ctx: &ToolCtx,
141            ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
142                let paths = manager_paths(args, ctx)?;
143                let mut provenance = crate::permission::ResourceProvenance::for_ctx(ctx)
144                    .with_cwd(ctx, optional_string(args, "cwd").map(std::path::Path::new))?;
145                let mutates = match self.name() {
146                    "git.workspace.create" | "git.workspace.release" | "git.workspace.retain" => {
147                        true
148                    }
149                    "git.workspace.prune" => !bool_arg(args, "dry_run", true),
150                    _ => false,
151                };
152                if mutates {
153                    provenance = provenance.with_risk(crate::trust::RiskKind::RepositoryMutation);
154                    if let Some(root) = paths.external_root {
155                        provenance = provenance.with_extra_target(ctx, &root)?;
156                    }
157                }
158                Ok(provenance)
159            }
160            fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
161                Box::pin(async move {
162                    let paths = manager_paths(&args, ctx)?;
163                    authorize_mutation(self.name(), &args, ctx, &paths).await?;
164                    ($body)(args, &paths).map_err(|e: RuntimeError| e)
165                })
166            }
167        }
168    };
169}
170
171basic_tool!(
172    GitWorkspaceCreate,
173    "git.workspace.create",
174    Tier::Two,
175    ApprovalLevel::Auto,
176    "Create or return a managed workspace recorded by the runtime.",
177    serde_json::json!({"type":"object","required":["id"],"properties":{"id":{"type":"string"},"cwd":{"type":"string"},"external_root":{"type":"string"},"branch":{"type":"string"},"base":{"type":"string"},"create_branch":{"type":"boolean"},"owner_session":{"type":"string"},"owner_flow":{"type":"string"}}}),
178    |args: ToolArgs, paths: &ManagerPaths| {
179        let id = required_string(&args, "id")?;
180        let item = manager(paths)?
181            .create(
182                id,
183                optional_string(&args, "branch"),
184                optional_string(&args, "base"),
185                bool_arg(&args, "create_branch", false),
186                optional_string(&args, "owner_session"),
187                optional_string(&args, "owner_flow"),
188            )
189            .map_err(|e| failure("git.workspace.create", e))?;
190        Ok(record_value(item))
191    }
192);
193basic_tool!(
194    GitWorkspaceList,
195    "git.workspace.list",
196    Tier::One,
197    ApprovalLevel::Auto,
198    "List managed workspaces.",
199    serde_json::json!({"type":"object","properties":{"cwd":{"type":"string"},"external_root":{"type":"string"}}}),
200    |_args: ToolArgs, paths: &ManagerPaths| {
201        let items = match existing_manager(paths)? {
202            Some(manager) => manager
203                .list()
204                .map_err(|e| failure("git.workspace.list", e))?,
205            None => Vec::new(),
206        };
207        Ok(Value::List(items.into_iter().map(record_value).collect()))
208    }
209);
210basic_tool!(
211    GitWorkspaceGet,
212    "git.workspace.get",
213    Tier::One,
214    ApprovalLevel::Auto,
215    "Get one managed workspace.",
216    serde_json::json!({"type":"object","required":["id"],"properties":{"id":{"type":"string"},"cwd":{"type":"string"},"external_root":{"type":"string"}}}),
217    |args: ToolArgs, paths: &ManagerPaths| {
218        let id = required_string(&args, "id")?;
219        let manager = existing_manager(paths)?
220            .ok_or_else(|| failure("git.workspace.get", format!("workspace {id} not found")))?;
221        Ok(record_value(
222            manager
223                .get(id)
224                .map_err(|e| failure("git.workspace.get", e))?,
225        ))
226    }
227);
228basic_tool!(
229    GitWorkspaceRelease,
230    "git.workspace.release",
231    Tier::Three,
232    ApprovalLevel::Approve,
233    "Release a managed workspace after ownership and dirty-state checks.",
234    serde_json::json!({"type":"object","required":["id","owner_session","owner_flow"],"properties":{"id":{"type":"string"},"cwd":{"type":"string"},"owner_session":{"type":"string"},"owner_flow":{"type":"string"},"force":{"type":"boolean"}}}),
235    |args: ToolArgs, paths: &ManagerPaths| {
236        Ok(record_value(
237            manager(paths)?
238                .release(
239                    required_string(&args, "id")?,
240                    optional_string(&args, "owner_session"),
241                    optional_string(&args, "owner_flow"),
242                    bool_arg(&args, "force", false),
243                )
244                .map_err(|e| failure("git.workspace.release", e))?,
245        ))
246    }
247);
248basic_tool!(
249    GitWorkspaceRetain,
250    "git.workspace.retain",
251    Tier::Two,
252    ApprovalLevel::Auto,
253    "Mark a managed workspace for retention or automatic cleanup.",
254    serde_json::json!({"type":"object","required":["id","owner_session","owner_flow"],"properties":{"id":{"type":"string"},"cwd":{"type":"string"},"owner_session":{"type":"string"},"owner_flow":{"type":"string"},"retained":{"type":"boolean"}}}),
255    |args: ToolArgs, paths: &ManagerPaths| {
256        Ok(record_value(
257            manager(paths)?
258                .retain(
259                    required_string(&args, "id")?,
260                    bool_arg(&args, "retained", true),
261                    optional_string(&args, "owner_session"),
262                    optional_string(&args, "owner_flow"),
263                )
264                .map_err(|e| failure("git.workspace.retain", e))?,
265        ))
266    }
267);
268basic_tool!(
269    GitWorkspacePrune,
270    "git.workspace.prune",
271    Tier::Three,
272    ApprovalLevel::Approve,
273    "List orphaned workspaces or release them when dry_run is false.",
274    serde_json::json!({"type":"object","properties":{"cwd":{"type":"string"},"dry_run":{"type":"boolean"}}}),
275    |args: ToolArgs, paths: &ManagerPaths| {
276        let dry_run = bool_arg(&args, "dry_run", true);
277        let items = if dry_run {
278            match existing_manager(paths)? {
279                Some(manager) => manager
280                    .prune(true)
281                    .map_err(|e| failure("git.workspace.prune", e))?,
282                None => Vec::new(),
283            }
284        } else {
285            manager(paths)?
286                .prune(false)
287                .map_err(|e| failure("git.workspace.prune", e))?
288        };
289        Ok(Value::List(items.into_iter().map(record_value).collect()))
290    }
291);
292
293#[allow(dead_code)]
294fn _workspace_error_type(_: WorkspaceError) {}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::path::Path;
300    use std::process::Command;
301
302    fn git(cwd: &Path, args: &[&str]) -> String {
303        let output = Command::new("git")
304            .args(args)
305            .current_dir(cwd)
306            .output()
307            .unwrap();
308        assert!(
309            output.status.success(),
310            "git {} failed: {}",
311            args.join(" "),
312            String::from_utf8_lossy(&output.stderr)
313        );
314        String::from_utf8_lossy(&output.stdout).trim().to_owned()
315    }
316
317    fn init_repo(path: &Path) {
318        git(path, &["init", "-q"]);
319        git(path, &["config", "user.email", "atman@example.invalid"]);
320        git(path, &["config", "user.name", "Atman Test"]);
321        git(path, &["config", "commit.gpgsign", "false"]);
322        git(path, &["config", "tag.gpgsign", "false"]);
323        std::fs::write(path.join("README.md"), "seed\n").unwrap();
324        git(path, &["add", "README.md"]);
325        git(path, &["commit", "-qm", "seed"]);
326    }
327
328    fn managed_ctx(root: &Path) -> ToolCtx {
329        ToolCtx::new()
330            .with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
331                root.to_path_buf(),
332            ))
333            .with_workspace(crate::git_workspace::WorkspaceBinding {
334                workspace_id: "test".into(),
335                repository_root: root.to_path_buf(),
336                path: root.to_path_buf(),
337                branch: None,
338            })
339    }
340
341    #[tokio::test]
342    async fn list_and_dry_run_prune_without_registry_have_no_filesystem_side_effects() {
343        let repo = tempfile::tempdir().unwrap();
344        init_repo(repo.path());
345        let exclude = repo.path().join(".git/info/exclude");
346        let exclude_before = std::fs::read(&exclude).unwrap();
347        let status_before = git(
348            repo.path(),
349            &["status", "--porcelain=v1", "--untracked-files=all"],
350        );
351        let ctx = managed_ctx(repo.path());
352
353        let listed = GitWorkspaceList
354            .call(ToolArgs::default(), &ctx)
355            .await
356            .unwrap();
357        let pruned = GitWorkspacePrune
358            .call(ToolArgs::default(), &ctx)
359            .await
360            .unwrap();
361
362        assert!(matches!(listed, Value::List(items) if items.is_empty()));
363        assert!(matches!(pruned, Value::List(items) if items.is_empty()));
364        assert!(!repo.path().join(".atman").exists());
365        assert_eq!(std::fs::read(&exclude).unwrap(), exclude_before);
366        assert_eq!(
367            git(
368                repo.path(),
369                &["status", "--porcelain=v1", "--untracked-files=all"]
370            ),
371            status_before
372        );
373    }
374
375    #[tokio::test]
376    async fn relative_external_root_is_resolved_once_for_authorization_and_manager() {
377        let root = tempfile::tempdir().unwrap();
378        let seed = tempfile::tempdir().unwrap();
379        init_repo(seed.path());
380        let bare = root.path().join("repo.git");
381        git(
382            root.path(),
383            &[
384                "clone",
385                "-q",
386                "--bare",
387                seed.path().to_str().unwrap(),
388                bare.file_name().unwrap().to_str().unwrap(),
389            ],
390        );
391        std::fs::create_dir(root.path().join("storage")).unwrap();
392        let ctx = managed_ctx(root.path());
393        let args = ToolArgs {
394            named: vec![
395                ("cwd".into(), Value::Str("repo.git".into())),
396                ("external_root".into(), Value::Str("storage".into())),
397                ("id".into(), Value::Str("relative-root".into())),
398            ],
399            ..ToolArgs::default()
400        };
401
402        let value = GitWorkspaceCreate.call(args, &ctx).await.unwrap();
403        let expected = root
404            .path()
405            .join("storage/.atman/worktrees/relative-root")
406            .canonicalize()
407            .unwrap();
408        let actual = match value {
409            Value::Struct(fields) => fields
410                .into_iter()
411                .find_map(|(key, value)| {
412                    (key == "worktree_path").then(|| match value {
413                        Value::Str(path) => PathBuf::from(path),
414                        other => panic!("unexpected worktree path value: {other:?}"),
415                    })
416                })
417                .unwrap(),
418            other => panic!("unexpected workspace value: {other:?}"),
419        };
420
421        assert_eq!(actual, expected);
422        assert!(root.path().join("storage/.atman/workspaces.json").exists());
423        assert!(
424            !std::env::current_dir()
425                .unwrap()
426                .join("storage/.atman/workspaces.json")
427                .exists()
428        );
429    }
430}