diff --git a/src/harness/workspace/git.rs b/src/harness/workspace/git.rs new file mode 100644 index 0000000..8d0f861 --- /dev/null +++ b/src/harness/workspace/git.rs @@ -0,0 +1,404 @@ +//! Git-worktree backed workspace isolation. +//! +//! This provider gives each agent run its own checkout under +//! `/.claude/worktrees/`, so parallel edit-capable workers can +//! operate without sharing one mutable filesystem root. It intentionally owns +//! only generic git/workspace behavior; host applications remain responsible +//! for product-specific audit, cleanup policy, and merge UX. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::harness::tool::SandboxMode; +use crate::harness::workspace::{WorkspaceDescriptor, WorkspaceIsolation}; +use crate::{Result, TinyAgentsError}; + +/// Directory, relative to the repository root, where isolated worktrees are +/// created. +pub const GIT_WORKTREE_SUBDIR: &str = ".claude/worktrees"; + +/// Which ref a new worktree branches from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitWorktreeBaseRef { + /// Branch off the repo's current `HEAD`. + Head, + /// Branch off the repository's default branch (`origin/HEAD`, then local + /// `HEAD`, then `main`). + Fresh, +} + +impl GitWorktreeBaseRef { + /// Parses a user/config string. Unknown or empty values default to + /// [`GitWorktreeBaseRef::Head`]. + pub fn parse(value: Option<&str>) -> Self { + match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() { + Some("fresh") => Self::Fresh, + _ => Self::Head, + } + } + + /// Stable lowercase label for logs and policy ids. + pub fn as_str(self) -> &'static str { + match self { + Self::Head => "head", + Self::Fresh => "fresh", + } + } +} + +/// Snapshot of a single git worktree's state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitWorktreeStatus { + /// Absolute path to the worktree checkout. + pub path: PathBuf, + /// Checked-out branch, or `(detached HEAD)` for detached worktrees. + pub branch: Option, + /// Whether staged, unstaged, or untracked changes are present. + pub is_dirty: bool, + /// Changed files relative to the worktree root. + pub changed_files: Vec, +} + +/// [`WorkspaceIsolation`] implementation backed by `git worktree`. +#[derive(Debug, Clone)] +pub struct GitWorktreeIsolation { + repo_root: PathBuf, + base_ref: GitWorktreeBaseRef, + sandbox: SandboxMode, + trusted_roots: Vec, +} + +impl GitWorktreeIsolation { + /// Creates an isolation provider rooted at a git repository. + pub fn new(repo_root: impl Into) -> Self { + Self { + repo_root: repo_root.into(), + base_ref: GitWorktreeBaseRef::Head, + sandbox: SandboxMode::Inherit, + trusted_roots: Vec::new(), + } + } + + /// Selects which ref newly prepared worktrees branch from. + pub fn with_base_ref(mut self, base_ref: GitWorktreeBaseRef) -> Self { + self.base_ref = base_ref; + self + } + + /// Advertises the sandbox expectation on prepared descriptors. + pub fn with_sandbox(mut self, sandbox: SandboxMode) -> Self { + self.sandbox = sandbox; + self + } + + /// Adds an extra root tools may touch alongside the isolated checkout. + pub fn with_trusted_root(mut self, root: impl Into) -> Self { + self.trusted_roots.push(root.into()); + self + } +} + +#[async_trait] +impl WorkspaceIsolation for GitWorktreeIsolation { + async fn prepare(&self, run_id: &str, agent: Option<&str>) -> Result { + let status = create_git_worktree(&self.repo_root, run_id, self.base_ref) + .map_err(|err| TinyAgentsError::Tool(err.to_string()))?; + let policy_id = match agent { + Some(agent) if !agent.is_empty() => format!("git.worktree:{agent}:{run_id}"), + _ => format!("git.worktree:{run_id}"), + }; + let mut descriptor = WorkspaceDescriptor::new(status.path) + .with_policy_id(policy_id) + .with_sandbox(self.sandbox); + for root in &self.trusted_roots { + descriptor = descriptor.with_trusted_root(root.clone()); + } + Ok(descriptor) + } + + async fn cleanup(&self, descriptor: &WorkspaceDescriptor) -> Result<()> { + remove_git_worktree(&self.repo_root, &descriptor.root, false) + .map_err(|err| TinyAgentsError::Tool(err.to_string())) + } +} + +/// Errors surfaced by the git worktree manager. +#[derive(Debug, thiserror::Error)] +pub enum GitWorktreeError { + /// The supplied path is not inside a git work tree. + #[error("path is not inside a git repository: {0}")] + NotAGitRepo(PathBuf), + + /// Dirty worktrees are not removed unless `force = true`. + #[error("worktree is dirty and force=false; refusing to remove: {0}")] + DirtyRefused(PathBuf), + + /// A git command exited unsuccessfully. + #[error("git command `{command}` failed: {stderr}")] + GitFailed { command: String, stderr: String }, + + /// Spawning git or creating the worktree parent directory failed. + #[error("io error running git: {0}")] + Io(#[from] std::io::Error), +} + +type GitResult = std::result::Result; + +/// Creates an isolated worktree for `run_id` and returns its status snapshot. +pub fn create_git_worktree( + repo_root: &Path, + run_id: &str, + base_ref: GitWorktreeBaseRef, +) -> GitResult { + let repo_top = validate_repo_root(repo_root)?; + let run_slug = sanitize_run_id(run_id); + let worktree_path = repo_top.join(GIT_WORKTREE_SUBDIR).join(&run_slug); + let branch = format!("worker/{run_slug}"); + let base = match base_ref { + GitWorktreeBaseRef::Head => "HEAD".to_string(), + GitWorktreeBaseRef::Fresh => resolve_fresh_base(&repo_top), + }; + + if let Some(parent) = worktree_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let worktree = worktree_path.to_string_lossy().to_string(); + git( + &repo_top, + &["worktree", "add", "-b", &branch, &worktree, &base], + )?; + git_worktree_status(&repo_top, &worktree_path) +} + +/// Lists worktrees registered on the repository at `repo_root`. +pub fn list_git_worktrees(repo_root: &Path) -> GitResult> { + let repo_top = validate_repo_root(repo_root)?; + let porcelain = git(&repo_top, &["worktree", "list", "--porcelain"])?; + let mut out = Vec::new(); + let mut cur_path: Option = None; + let mut cur_branch: Option = None; + + let mut flush = |path: &mut Option, branch: &mut Option| { + if let Some(path) = path.take() { + let (is_dirty, changed_files) = dirty_state(&path).unwrap_or((false, Vec::new())); + out.push(GitWorktreeStatus { + path, + branch: branch.take(), + is_dirty, + changed_files, + }); + } else { + *branch = None; + } + }; + + for line in porcelain.lines() { + if let Some(rest) = line.strip_prefix("worktree ") { + flush(&mut cur_path, &mut cur_branch); + cur_path = Some(PathBuf::from(rest.trim())); + } else if let Some(rest) = line.strip_prefix("branch ") { + let trimmed = rest.trim(); + cur_branch = Some( + trimmed + .strip_prefix("refs/heads/") + .unwrap_or(trimmed) + .to_string(), + ); + } else if line.trim() == "detached" { + cur_branch = Some("(detached HEAD)".to_string()); + } + } + flush(&mut cur_path, &mut cur_branch); + Ok(out) +} + +/// Returns branch, dirty, and changed-file status for one worktree. +pub fn git_worktree_status(repo_root: &Path, worktree_path: &Path) -> GitResult { + validate_repo_root(repo_root)?; + if !worktree_path.exists() { + return Err(GitWorktreeError::NotAGitRepo(worktree_path.to_path_buf())); + } + let branch = git(worktree_path, &["rev-parse", "--abbrev-ref", "HEAD"]) + .ok() + .map(|branch| { + if branch == "HEAD" { + "(detached HEAD)".to_string() + } else { + branch + } + }); + let (is_dirty, changed_files) = dirty_state(worktree_path)?; + Ok(GitWorktreeStatus { + path: worktree_path.to_path_buf(), + branch, + is_dirty, + changed_files, + }) +} + +/// Human-readable diff stat of working changes vs `HEAD`, including untracked +/// files. +pub fn git_worktree_diff_summary(repo_root: &Path, worktree_path: &Path) -> GitResult { + validate_repo_root(repo_root)?; + let stat = git(worktree_path, &["diff", "HEAD", "--stat"])?; + let untracked = git( + worktree_path, + &["ls-files", "--others", "--exclude-standard"], + )?; + let mut parts = Vec::new(); + if !stat.is_empty() { + parts.push(stat); + } + if !untracked.is_empty() { + parts.push( + untracked + .lines() + .map(|line| format!(" {line} (untracked)")) + .collect::>() + .join("\n"), + ); + } + Ok(parts.join("\n")) +} + +/// Removes a worktree. Dirty worktrees are refused unless `force = true`. +pub fn remove_git_worktree(repo_root: &Path, worktree_path: &Path, force: bool) -> GitResult<()> { + let repo_top = validate_repo_root(repo_root)?; + let (is_dirty, _) = dirty_state(worktree_path).unwrap_or((false, Vec::new())); + if is_dirty && !force { + return Err(GitWorktreeError::DirtyRefused(worktree_path.to_path_buf())); + } + + let worktree = worktree_path.to_string_lossy().to_string(); + let mut args = vec!["worktree", "remove", &worktree]; + if force { + args.push("--force"); + } + git(&repo_top, &args)?; + Ok(()) +} + +/// Detects changed files touched by more than one sibling worker. +pub fn detect_worktree_overlaps( + per_worker: &[(String, Vec)], +) -> std::collections::BTreeMap> { + use std::collections::{BTreeMap, BTreeSet}; + + let mut by_file: BTreeMap> = BTreeMap::new(); + for (worker_id, files) in per_worker { + let mut seen = BTreeSet::new(); + for file in files { + if seen.insert(file.clone()) { + by_file + .entry(file.clone()) + .or_default() + .push(worker_id.clone()); + } + } + } + + by_file + .into_iter() + .filter_map(|(file, mut workers)| { + workers.sort(); + workers.dedup(); + (workers.len() > 1).then_some((file, workers)) + }) + .collect() +} + +fn git(cwd: &Path, args: &[&str]) -> GitResult { + let output = Command::new("git").current_dir(cwd).args(args).output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(GitWorktreeError::GitFailed { + command: format!("git {}", args.join(" ")), + stderr, + }); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn git_raw(cwd: &Path, args: &[&str]) -> GitResult { + let output = Command::new("git").current_dir(cwd).args(args).output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(GitWorktreeError::GitFailed { + command: format!("git {}", args.join(" ")), + stderr, + }); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn validate_repo_root(repo_root: &Path) -> GitResult { + if !repo_root.exists() { + return Err(GitWorktreeError::NotAGitRepo(repo_root.to_path_buf())); + } + let inside = git(repo_root, &["rev-parse", "--is-inside-work-tree"]) + .map_err(|_| GitWorktreeError::NotAGitRepo(repo_root.to_path_buf()))?; + if inside.trim() != "true" { + return Err(GitWorktreeError::NotAGitRepo(repo_root.to_path_buf())); + } + let top = git(repo_root, &["rev-parse", "--show-toplevel"]) + .map_err(|_| GitWorktreeError::NotAGitRepo(repo_root.to_path_buf()))?; + Ok(PathBuf::from(top.trim())) +} + +fn resolve_fresh_base(repo_top: &Path) -> String { + if let Ok(sym) = git( + repo_top, + &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + ) && !sym.is_empty() + { + return sym; + } + if let Ok(head) = git(repo_top, &["symbolic-ref", "--short", "HEAD"]) + && !head.is_empty() + { + return head; + } + "main".to_string() +} + +fn dirty_state(worktree_path: &Path) -> GitResult<(bool, Vec)> { + let porcelain = git_raw(worktree_path, &["status", "--porcelain"])?; + let mut changed = Vec::new(); + for line in porcelain.lines() { + if line.len() > 3 { + let path = line[3..].trim_end(); + let path = path.rsplit(" -> ").next().unwrap_or(path); + changed.push(PathBuf::from(path)); + } + } + changed.sort(); + changed.dedup(); + Ok((!changed.is_empty(), changed)) +} + +fn sanitize_run_id(run_id: &str) -> String { + let cleaned: String = run_id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '-' + } + }) + .collect(); + let trimmed = cleaned.trim_matches('-'); + if trimmed.is_empty() { + "worker".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod test; diff --git a/src/harness/workspace/git/test.rs b/src/harness/workspace/git/test.rs new file mode 100644 index 0000000..faf14e9 --- /dev/null +++ b/src/harness/workspace/git/test.rs @@ -0,0 +1,254 @@ +//! Tests for the git-worktree isolation provider. + +use super::*; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "tinyagents-worktree-test-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn git_available() -> bool { + Command::new("git") + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn run(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .current_dir(dir) + .args(args) + .output() + .expect("git invocation"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); +} + +fn init_repo() -> (TempDir, PathBuf) { + let tmp = TempDir::new(); + let root = tmp.path().to_path_buf(); + run(&root, &["init", "-b", "main"]); + run(&root, &["config", "user.email", "test@example.com"]); + run(&root, &["config", "user.name", "Test User"]); + std::fs::write(root.join("README.md"), "hello\n").unwrap(); + run(&root, &["add", "README.md"]); + run(&root, &["commit", "-m", "initial"]); + (tmp, root) +} + +#[test] +fn validate_repo_root_rejects_non_repo() { + if !git_available() { + return; + } + let tmp = TempDir::new(); + let err = create_git_worktree(tmp.path(), "run-1", GitWorktreeBaseRef::Head).unwrap_err(); + assert!(matches!(err, GitWorktreeError::NotAGitRepo(_))); +} + +#[test] +fn create_then_status_reports_clean_worktree() { + if !git_available() { + return; + } + let (_tmp, root) = init_repo(); + let status = + create_git_worktree(&root, "run-1", GitWorktreeBaseRef::Head).expect("create worktree"); + assert!(status.path.exists()); + assert_eq!(status.branch.as_deref(), Some("worker/run-1")); + assert!(!status.is_dirty); + assert!(status.changed_files.is_empty()); + assert!(status.path.ends_with(Path::new(".claude/worktrees/run-1"))); +} + +#[test] +fn list_includes_created_worktrees() { + if !git_available() { + return; + } + let (_tmp, root) = init_repo(); + create_git_worktree(&root, "run-a", GitWorktreeBaseRef::Head).expect("create a"); + create_git_worktree(&root, "run-b", GitWorktreeBaseRef::Fresh).expect("create b"); + + let all = list_git_worktrees(&root).expect("list worktrees"); + assert!(all.len() >= 3, "expected main + two worktrees, got {all:?}"); + let branches: Vec<_> = all + .iter() + .filter_map(|worktree| worktree.branch.clone()) + .collect(); + assert!(branches.iter().any(|branch| branch == "worker/run-a")); + assert!(branches.iter().any(|branch| branch == "worker/run-b")); +} + +#[test] +fn status_detects_dirty_changes() { + if !git_available() { + return; + } + let (_tmp, root) = init_repo(); + let status = create_git_worktree(&root, "run-dirty", GitWorktreeBaseRef::Head).expect("create"); + std::fs::write(status.path.join("README.md"), "changed\n").unwrap(); + std::fs::write(status.path.join("new.txt"), "fresh\n").unwrap(); + + let status = git_worktree_status(&root, &status.path).expect("status"); + assert!(status.is_dirty); + let names: Vec<_> = status + .changed_files + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(); + assert!(names.iter().any(|name| name.contains("README.md"))); + assert!(names.iter().any(|name| name.contains("new.txt"))); +} + +#[test] +fn diff_summary_lists_tracked_and_untracked() { + if !git_available() { + return; + } + let (_tmp, root) = init_repo(); + let status = create_git_worktree(&root, "run-diff", GitWorktreeBaseRef::Head).expect("create"); + std::fs::write(status.path.join("README.md"), "changed body\n").unwrap(); + std::fs::write(status.path.join("brand_new.txt"), "x\n").unwrap(); + + let summary = git_worktree_diff_summary(&root, &status.path).expect("diff"); + assert!(summary.contains("README.md")); + assert!(summary.contains("brand_new.txt") && summary.contains("untracked")); +} + +#[test] +fn remove_refuses_dirty_without_force() { + if !git_available() { + return; + } + let (_tmp, root) = init_repo(); + let status = create_git_worktree(&root, "run-keep", GitWorktreeBaseRef::Head).expect("create"); + std::fs::write(status.path.join("README.md"), "dirty\n").unwrap(); + + let err = remove_git_worktree(&root, &status.path, false).expect_err("must refuse dirty"); + assert!(matches!(err, GitWorktreeError::DirtyRefused(_))); + assert!(status.path.exists()); +} + +#[test] +fn remove_force_deletes_dirty_worktree() { + if !git_available() { + return; + } + let (_tmp, root) = init_repo(); + let status = create_git_worktree(&root, "run-force", GitWorktreeBaseRef::Head).expect("create"); + std::fs::write(status.path.join("README.md"), "dirty\n").unwrap(); + + remove_git_worktree(&root, &status.path, true).expect("force remove"); + assert!(!status.path.exists()); +} + +#[test] +fn remove_clean_worktree_succeeds() { + if !git_available() { + return; + } + let (_tmp, root) = init_repo(); + let status = create_git_worktree(&root, "run-clean", GitWorktreeBaseRef::Head).expect("create"); + + remove_git_worktree(&root, &status.path, false).expect("clean remove"); + assert!(!status.path.exists()); +} + +#[test] +fn base_ref_parse_defaults_to_head() { + assert_eq!(GitWorktreeBaseRef::parse(None), GitWorktreeBaseRef::Head); + assert_eq!( + GitWorktreeBaseRef::parse(Some("head")), + GitWorktreeBaseRef::Head + ); + assert_eq!( + GitWorktreeBaseRef::parse(Some(" Fresh ")), + GitWorktreeBaseRef::Fresh + ); + assert_eq!( + GitWorktreeBaseRef::parse(Some("garbage")), + GitWorktreeBaseRef::Head + ); +} + +#[test] +fn sanitize_run_id_strips_unsafe_chars() { + assert_eq!(sanitize_run_id("sub-1234"), "sub-1234"); + assert_eq!(sanitize_run_id("a/b\\c"), "a-b-c"); + assert_eq!(sanitize_run_id("///"), "worker"); + assert_eq!(sanitize_run_id(""), "worker"); +} + +#[test] +fn detect_overlaps_flags_shared_files() { + let per_worker = vec![ + ( + "w1".to_string(), + vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")], + ), + ( + "w2".to_string(), + vec![PathBuf::from("src/b.rs"), PathBuf::from("src/c.rs")], + ), + ("w3".to_string(), vec![PathBuf::from("src/c.rs")]), + ]; + + let overlaps = detect_worktree_overlaps(&per_worker); + assert_eq!(overlaps.len(), 2); + assert_eq!( + overlaps.get(&PathBuf::from("src/b.rs")).unwrap(), + &vec!["w1".to_string(), "w2".to_string()] + ); + assert_eq!( + overlaps.get(&PathBuf::from("src/c.rs")).unwrap(), + &vec!["w2".to_string(), "w3".to_string()] + ); +} + +#[test] +fn detect_overlaps_empty_when_disjoint_or_duplicate_within_one_worker() { + let disjoint = vec![ + ("w1".to_string(), vec![PathBuf::from("a.rs")]), + ("w2".to_string(), vec![PathBuf::from("b.rs")]), + ]; + assert!(detect_worktree_overlaps(&disjoint).is_empty()); + + let duplicate = vec![( + "w1".to_string(), + vec![PathBuf::from("a.rs"), PathBuf::from("a.rs")], + )]; + assert!(detect_worktree_overlaps(&duplicate).is_empty()); +} diff --git a/src/harness/workspace/mod.rs b/src/harness/workspace/mod.rs index 2897557..51c1166 100644 --- a/src/harness/workspace/mod.rs +++ b/src/harness/workspace/mod.rs @@ -9,9 +9,11 @@ //! Application-specific worktree/sandbox providers implement //! [`WorkspaceIsolation`] themselves. +mod git; mod policy; mod types; +pub use git::*; pub use types::*; use std::path::PathBuf;