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}