1use crate::types::AgentSpec;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::path::{Path, PathBuf};
23
24pub const WORKSPACE_METADATA_KEY: &str = "workspace";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum WorkspaceMode {
32 Directory,
34 GitWorktree,
39}
40
41#[derive(Debug, Clone)]
43pub struct WorkspaceConfig {
44 pub base: PathBuf,
48 pub mode: WorkspaceMode,
49 pub repo: Option<PathBuf>,
55 pub rev: Option<String>,
60}
61
62impl WorkspaceConfig {
63 pub fn directory(base: impl Into<PathBuf>) -> Self {
64 Self {
65 base: base.into(),
66 mode: WorkspaceMode::Directory,
67 repo: None,
68 rev: None,
69 }
70 }
71
72 pub fn git_worktree(base: impl Into<PathBuf>) -> Self {
73 Self {
74 base: base.into(),
75 mode: WorkspaceMode::GitWorktree,
76 repo: None,
77 rev: None,
78 }
79 }
80
81 pub fn git_worktree_at(repo: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
84 Self {
85 base: base.into(),
86 mode: WorkspaceMode::GitWorktree,
87 repo: Some(repo.into()),
88 rev: None,
89 }
90 }
91
92 pub fn with_rev(mut self, rev: impl Into<String>) -> Self {
94 self.rev = Some(rev.into());
95 self
96 }
97}
98
99fn sanitize(name: &str) -> String {
105 let s: String = name
106 .chars()
107 .map(|c| {
108 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
109 c
110 } else {
111 '-'
112 }
113 })
114 .collect();
115 if s.is_empty() {
116 "agent".to_string()
117 } else {
118 s
119 }
120}
121
122#[derive(Debug)]
125pub struct AgentWorkspace {
126 path: PathBuf,
127 mode: WorkspaceMode,
128 repo_root: Option<PathBuf>,
130 cleanup_on_drop: bool,
131}
132
133impl AgentWorkspace {
134 pub fn reopen_git_worktree(repo: &Path, path: &Path) -> Result<Self, String> {
138 let repo = repo.canonicalize().map_err(|e| e.to_string())?;
139 let path = path.canonicalize().map_err(|e| e.to_string())?;
140 let root = git_repo_root(&path)
141 .ok_or("retained directory is not a git worktree")?
142 .canonicalize()
143 .map_err(|e| e.to_string())?;
144 if root != path || repo == path || !path.join(".git").is_file() {
145 return Err(
146 "retained path must be a linked worktree root, not the user's checkout".into(),
147 );
148 }
149 let common = |dir: &Path| -> Result<PathBuf, String> {
150 let out = std::process::Command::new("git")
151 .arg("-C")
152 .arg(dir)
153 .args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
154 .output()
155 .map_err(|e| e.to_string())?;
156 if !out.status.success() {
157 return Err("cannot identify retained worktree repository".into());
158 }
159 PathBuf::from(
160 String::from_utf8(out.stdout)
161 .map_err(|e| e.to_string())?
162 .trim(),
163 )
164 .canonicalize()
165 .map_err(|e| e.to_string())
166 };
167 if common(&repo)? != common(&path)? {
168 return Err("retained worktree belongs to another repository".into());
169 }
170 Ok(Self {
171 path,
172 mode: WorkspaceMode::GitWorktree,
173 repo_root: Some(repo),
174 cleanup_on_drop: false,
175 })
176 }
177
178 pub fn enable_cleanup(&mut self) {
180 self.cleanup_on_drop = true;
181 }
182
183 pub fn provision(config: &WorkspaceConfig, agent_name: &str) -> Result<Self, String> {
185 let path = config.base.join(sanitize(agent_name));
186 match config.mode {
187 WorkspaceMode::Directory => {
188 std::fs::create_dir_all(&path)
189 .map_err(|e| format!("create workspace dir {}: {e}", path.display()))?;
190 Ok(Self {
191 path,
192 mode: WorkspaceMode::Directory,
193 repo_root: None,
194 cleanup_on_drop: true,
195 })
196 }
197 WorkspaceMode::GitWorktree => {
198 use std::ffi::OsStr;
199 let rev = config.rev.as_deref().unwrap_or("HEAD");
200 if rev.is_empty() || rev.starts_with('-') {
203 return Err(format!("invalid worktree revision {rev:?}"));
204 }
205 let repo_hint = config.repo.as_ref().unwrap_or(&config.base);
206 let repo_root = git_repo_root(repo_hint).ok_or_else(|| {
207 format!(
208 "git_worktree workspace requires {} to be inside a git repo",
209 repo_hint.display()
210 )
211 })?;
212 std::fs::create_dir_all(&config.base)
213 .map_err(|e| format!("create workspace base {}: {e}", config.base.display()))?;
214 let _ = run_git(
219 &repo_root,
220 &[
221 OsStr::new("worktree"),
222 OsStr::new("remove"),
223 OsStr::new("--force"),
224 path.as_os_str(),
225 ],
226 );
227 let _ = run_git(&repo_root, &[OsStr::new("worktree"), OsStr::new("prune")]);
228 if path.exists() {
229 let _ = std::fs::remove_dir_all(&path);
230 }
231 run_git(
232 &repo_root,
233 &[
234 OsStr::new("worktree"),
235 OsStr::new("add"),
236 OsStr::new("--detach"),
237 path.as_os_str(),
238 OsStr::new(rev),
239 ],
240 )?;
241 Ok(Self {
242 path,
243 mode: WorkspaceMode::GitWorktree,
244 repo_root: Some(repo_root),
245 cleanup_on_drop: true,
246 })
247 }
248 }
249 }
250
251 pub fn path(&self) -> &Path {
253 &self.path
254 }
255
256 pub fn inject(&self, mut spec: AgentSpec) -> AgentSpec {
258 spec.metadata.insert(
259 WORKSPACE_METADATA_KEY.to_string(),
260 Value::String(self.path.to_string_lossy().into_owned()),
261 );
262 spec
263 }
264}
265
266impl Drop for AgentWorkspace {
267 fn drop(&mut self) {
268 if !self.cleanup_on_drop {
269 return;
270 }
271 match self.mode {
272 WorkspaceMode::Directory => {
273 let _ = std::fs::remove_dir_all(&self.path);
274 }
275 WorkspaceMode::GitWorktree => {
276 if let Some(root) = &self.repo_root {
277 use std::ffi::OsStr;
278 let _ = run_git(
280 root,
281 &[
282 OsStr::new("worktree"),
283 OsStr::new("remove"),
284 OsStr::new("--force"),
285 self.path.as_os_str(),
286 ],
287 );
288 let _ = std::fs::remove_dir_all(&self.path);
289 }
290 }
291 }
292 }
293}
294
295fn git_repo_root(dir: &Path) -> Option<PathBuf> {
297 let out = std::process::Command::new("git")
298 .arg("-C")
299 .arg(dir)
300 .args(["rev-parse", "--show-toplevel"])
301 .output()
302 .ok()?;
303 if !out.status.success() {
304 return None;
305 }
306 let root = String::from_utf8(out.stdout).ok()?.trim().to_string();
307 if root.is_empty() {
308 None
309 } else {
310 Some(PathBuf::from(root))
311 }
312}
313
314fn run_git(repo_root: &Path, args: &[&std::ffi::OsStr]) -> Result<(), String> {
315 let out = std::process::Command::new("git")
316 .arg("-C")
317 .arg(repo_root)
318 .args(args)
319 .output()
320 .map_err(|e| format!("git {:?}: {e}", args))?;
321 if out.status.success() {
322 Ok(())
323 } else {
324 Err(format!(
325 "git {:?} failed: {}",
326 args,
327 String::from_utf8_lossy(&out.stderr).trim()
328 ))
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 fn unique_base(tag: &str) -> PathBuf {
337 use std::sync::atomic::{AtomicU64, Ordering};
339 static N: AtomicU64 = AtomicU64::new(0);
340 let n = N.fetch_add(1, Ordering::Relaxed);
341 std::env::temp_dir().join(format!("car-ws-{tag}-{}-{n}", std::process::id()))
342 }
343
344 #[test]
345 fn directory_workspace_is_created_injected_and_cleaned() {
346 let base = unique_base("dir");
347 let cfg = WorkspaceConfig::directory(&base);
348 let path;
349 {
350 let ws = AgentWorkspace::provision(&cfg, "alice/../x").unwrap();
351 path = ws.path().to_path_buf();
352 assert!(path.exists() && path.is_dir());
353 assert_eq!(path.parent().unwrap(), base);
355 assert!(!path.to_string_lossy().contains(".."));
356
357 let spec = ws.inject(AgentSpec::new("alice", "sys"));
358 assert_eq!(
359 spec.metadata.get(WORKSPACE_METADATA_KEY).unwrap(),
360 &Value::String(path.to_string_lossy().into_owned())
361 );
362 }
363 assert!(!path.exists(), "workspace should be removed on drop");
365 let _ = std::fs::remove_dir_all(&base);
366 }
367
368 #[test]
369 fn git_worktree_at_provisions_outside_the_repo() {
370 if std::process::Command::new("git")
373 .arg("--version")
374 .output()
375 .is_err()
376 {
377 return;
378 }
379 let repo = unique_base("repo");
380 std::fs::create_dir_all(&repo).unwrap();
381 for args in [
382 vec!["init", "-q"],
383 vec![
384 "-c",
385 "user.name=t",
386 "-c",
387 "user.email=t@t",
388 "commit",
389 "-q",
390 "--allow-empty",
391 "-m",
392 "init",
393 ],
394 ] {
395 let out = std::process::Command::new("git")
396 .arg("-C")
397 .arg(&repo)
398 .args(&args)
399 .output()
400 .unwrap();
401 assert!(
402 out.status.success(),
403 "git {args:?}: {}",
404 String::from_utf8_lossy(&out.stderr)
405 );
406 }
407
408 let base = unique_base("wt-base");
409 let cfg = WorkspaceConfig::git_worktree_at(&repo, &base);
410 let path;
411 {
412 let ws = AgentWorkspace::provision(&cfg, "session-1").unwrap();
413 path = ws.path().to_path_buf();
414 assert!(
415 path.starts_with(&base),
416 "worktree must live under base, not the repo"
417 );
418 assert!(path.join(".git").exists(), "worktree checkout expected");
419 let out = std::process::Command::new("git")
421 .arg("-C")
422 .arg(&repo)
423 .args(["status", "--porcelain"])
424 .output()
425 .unwrap();
426 assert!(out.stdout.is_empty(), "repo status must stay clean");
427 }
428 assert!(!path.exists(), "worktree removed on drop");
429 let _ = std::fs::remove_dir_all(&base);
430 let _ = std::fs::remove_dir_all(&repo);
431 }
432
433 fn git_out(dir: &Path, args: &[&str]) -> String {
434 let out = std::process::Command::new("git")
435 .arg("-C")
436 .arg(dir)
437 .args(args)
438 .output()
439 .unwrap();
440 assert!(
441 out.status.success(),
442 "git {args:?}: {}",
443 String::from_utf8_lossy(&out.stderr)
444 );
445 String::from_utf8(out.stdout).unwrap().trim().to_string()
446 }
447
448 #[test]
449 fn reopening_retained_work_is_nondestructive_and_repository_bound() {
450 let repo = unique_base("reopen-repo");
451 let other = unique_base("reopen-other");
452 let base = unique_base("reopen-worktrees");
453 for root in [&repo, &other] {
454 std::fs::create_dir_all(root).unwrap();
455 git_out(root, &["init", "-q"]);
456 git_out(
457 root,
458 &[
459 "-c",
460 "user.name=t",
461 "-c",
462 "user.email=t@t",
463 "commit",
464 "--allow-empty",
465 "-qm",
466 "initial",
467 ],
468 );
469 }
470 let workspace =
471 AgentWorkspace::provision(&WorkspaceConfig::git_worktree_at(&repo, &base), "retained")
472 .unwrap();
473 let path = workspace.path().to_path_buf();
474 std::fs::write(path.join("partial.txt"), "unfinished").unwrap();
475 std::mem::forget(workspace);
476 assert!(AgentWorkspace::reopen_git_worktree(&other, &path).is_err());
477 assert!(AgentWorkspace::reopen_git_worktree(&repo, &repo).is_err());
478 let reopened = AgentWorkspace::reopen_git_worktree(&repo, &path).unwrap();
479 drop(reopened);
480 assert_eq!(
481 std::fs::read_to_string(path.join("partial.txt")).unwrap(),
482 "unfinished"
483 );
484 let mut delivered = AgentWorkspace::reopen_git_worktree(&repo, &path).unwrap();
485 delivered.enable_cleanup();
486 drop(delivered);
487 assert!(!path.exists());
488 assert!(repo.is_dir());
489 for root in [repo, other, base] {
490 let _ = std::fs::remove_dir_all(root);
491 }
492 }
493
494 #[test]
495 fn git_worktree_checks_out_the_requested_rev_not_head() {
496 if std::process::Command::new("git")
497 .arg("--version")
498 .output()
499 .is_err()
500 {
501 return;
502 }
503 let repo = unique_base("rev-repo");
504 std::fs::create_dir_all(&repo).unwrap();
505 let commit = ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q"];
506 git_out(&repo, &["init", "-q"]);
507 git_out(
508 &repo,
509 &[&commit[..], &["--allow-empty", "-m", "one"]].concat(),
510 );
511 let first = git_out(&repo, &["rev-parse", "HEAD"]);
512 git_out(
513 &repo,
514 &[&commit[..], &["--allow-empty", "-m", "two"]].concat(),
515 );
516 let head = git_out(&repo, &["rev-parse", "HEAD"]);
517 assert_ne!(first, head);
518
519 let base = unique_base("rev-wt");
520 let at_head =
523 AgentWorkspace::provision(&WorkspaceConfig::git_worktree_at(&repo, &base), "h")
524 .unwrap();
525 assert_eq!(git_out(at_head.path(), &["rev-parse", "HEAD"]), head);
526 drop(at_head);
527
528 let cfg = WorkspaceConfig::git_worktree_at(&repo, &base).with_rev(first.clone());
529 let ws = AgentWorkspace::provision(&cfg, "r").unwrap();
530 assert_eq!(git_out(ws.path(), &["rev-parse", "HEAD"]), first);
531 drop(ws);
532 let _ = std::fs::remove_dir_all(&base);
533 let _ = std::fs::remove_dir_all(&repo);
534 }
535
536 #[test]
537 fn git_worktree_refuses_a_rev_that_git_would_parse_as_a_flag() {
538 let base = unique_base("rev-flag");
539 for rev in ["", "-b", "--orphan=x"] {
540 let cfg = WorkspaceConfig::git_worktree_at(&base, &base).with_rev(rev);
541 let err = AgentWorkspace::provision(&cfg, "x").unwrap_err();
542 assert!(err.contains("invalid worktree revision"), "{rev:?}: {err}");
543 }
544 }
545
546 #[test]
547 fn distinct_agents_get_distinct_dirs() {
548 let base = unique_base("distinct");
549 let cfg = WorkspaceConfig::directory(&base);
550 let a = AgentWorkspace::provision(&cfg, "a").unwrap();
551 let b = AgentWorkspace::provision(&cfg, "b").unwrap();
552 assert_ne!(a.path(), b.path());
553 drop(a);
554 drop(b);
555 let _ = std::fs::remove_dir_all(&base);
556 }
557}