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}