Skip to main content

telos_agent/orchestration/subagent/
worktree.rs

1//! Git worktree isolation for subagents — creation, fast-resume, cleanup.
2//!
3//! Provides safe worktree creation with slug validation, canonical git-root
4//! resolution, fast-resume, dirty-state detection, and cleanup. Designed to
5//! prevent worktree nesting and protect the main repo from accidental mutation
6//! by isolated subagent runs.
7
8#[cfg(windows)]
9use std::os::windows::process::CommandExt;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use crate::error::AgentError;
14
15/// Info about a created or resumed worktree.
16#[derive(Debug, Clone)]
17pub struct WorktreeInfo {
18    /// Absolute path to the worktree working tree.
19    pub path: PathBuf,
20    /// Branch name (None when using --detach).
21    pub branch: Option<String>,
22    /// Whether this worktree already existed (fast-resume path).
23    pub was_existing: bool,
24}
25
26/// Create or resume a git worktree for a subagent.
27///
28/// Creates the worktree at `<git_root>/.worktrees/subagents/<agent_id>`.
29/// If a worktree already exists at that path, it is resumed (fast path).
30/// Uses a detached HEAD for simplicity.
31pub fn create_subagent_worktree(
32    parent_cwd: &Path,
33    agent_id: &str,
34) -> Result<WorktreeInfo, AgentError> {
35    // Validate the agent_id as a safe slug to prevent path traversal.
36    validate_slug(agent_id)?;
37
38    // Find the canonical (top-level) git root, NOT a sub-worktree's root.
39    let git_root = find_canonical_git_root(parent_cwd)?;
40
41    let worktree_dir = git_root.join(".worktrees").join("subagents").join(agent_id);
42
43    // Fast-resume: check if the worktree already exists.
44    if worktree_dir.exists() {
45        // Verify it's actually a git worktree (has a .git file pointing back).
46        let git_file = worktree_dir.join(".git");
47        if git_file.exists()
48            && std::fs::read_to_string(&git_file).unwrap_or_default().contains("gitdir:")
49        {
50            return Ok(WorktreeInfo { path: worktree_dir, branch: None, was_existing: true });
51        }
52        // Corrupt — remove and recreate.
53        let _ = std::fs::remove_dir_all(&worktree_dir);
54    }
55
56    // Create parent directories.
57    std::fs::create_dir_all(
58        worktree_dir
59            .parent()
60            .ok_or_else(|| AgentError::Config("worktree parent directory not found".into()))?,
61    )
62    .map_err(|e| AgentError::Config(format!("failed to create worktree parent dir: {e}")))?;
63
64    // Create the worktree with detached HEAD.
65    let mut command = hidden_command("git");
66    let output = command
67        .args([
68            "worktree",
69            "add",
70            "--detach",
71            worktree_dir
72                .to_str()
73                .ok_or_else(|| AgentError::Config("worktree path is not valid UTF-8".into()))?,
74            "HEAD",
75        ])
76        .current_dir(&git_root)
77        .output()
78        .map_err(|e| AgentError::Config(format!("failed to run git worktree add: {e}")))?;
79
80    if !output.status.success() {
81        let stderr = String::from_utf8_lossy(&output.stderr);
82        return Err(AgentError::Config(format!("git worktree add failed: {}", stderr.trim())));
83    }
84
85    Ok(WorktreeInfo { path: worktree_dir, branch: None, was_existing: false })
86}
87
88/// Clean up a subagent worktree.
89///
90/// Removes the worktree from disk (via `git worktree remove --force`) and
91/// prunes stale worktree metadata. If `safety_check` is true, checks for
92/// uncommitted changes before removing and returns an error if the worktree
93/// is dirty.
94pub fn remove_subagent_worktree(
95    worktree_path: &Path,
96    safety_check: bool,
97) -> Result<(), AgentError> {
98    if !worktree_path.exists() {
99        return Ok(()); // Nothing to clean up.
100    }
101
102    if safety_check && has_worktree_changes(worktree_path)? {
103        return Err(AgentError::Config(format!(
104            "worktree at {} has uncommitted changes — refusing to remove",
105            worktree_path.display()
106        )));
107    }
108
109    // Run git worktree remove
110    let mut command = hidden_command("git");
111    let output = command
112        .args(["worktree", "remove", "--force", worktree_path.to_str().unwrap_or("")])
113        .output()
114        .map_err(|e| AgentError::Config(format!("failed to run git worktree remove: {e}")))?;
115
116    if !output.status.success() {
117        let stderr = String::from_utf8_lossy(&output.stderr);
118
119        // Fallback: if git worktree remove fails, just delete the directory.
120        if stderr.contains("is not a working tree") || stderr.contains("not found") {
121            let _ = std::fs::remove_dir_all(worktree_path);
122        } else {
123            return Err(AgentError::Config(format!(
124                "git worktree remove failed: {}",
125                stderr.trim()
126            )));
127        }
128    }
129
130    // Prune stale metadata.
131    let _ = hidden_command("git").args(["worktree", "prune"]).output();
132
133    Ok(())
134}
135
136/// Check if a worktree has uncommitted changes (modified or untracked files).
137pub fn has_worktree_changes(worktree_path: &Path) -> Result<bool, AgentError> {
138    let mut command = hidden_command("git");
139    let output = command
140        .args(["status", "--porcelain", "-uno"])
141        .current_dir(worktree_path)
142        .output()
143        .map_err(|e| AgentError::Config(format!("failed to check git status: {e}")))?;
144
145    let stdout = String::from_utf8_lossy(&output.stdout);
146    Ok(!stdout.trim().is_empty())
147}
148
149fn hidden_command(program: &str) -> Command {
150    #[cfg_attr(not(windows), allow(unused_mut))]
151    let mut command = Command::new(program);
152    #[cfg(windows)]
153    {
154        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
155        command.creation_flags(CREATE_NO_WINDOW);
156    }
157    command
158}
159
160/// Clean up stale ephemeral subagent worktrees older than the cutoff.
161///
162/// Scans `<git_root>/.worktrees/subagents/` for subdirectories matching
163/// subagent naming patterns and removes ones that have no uncommitted changes.
164pub fn cleanup_stale_subagent_worktrees(
165    git_root: &Path,
166    _cutoff_seconds: u64,
167) -> Result<Vec<PathBuf>, AgentError> {
168    let subagents_dir = git_root.join(".worktrees").join("subagents");
169    if !subagents_dir.exists() {
170        return Ok(Vec::new());
171    }
172
173    let mut cleaned = Vec::new();
174
175    let entries = std::fs::read_dir(&subagents_dir)
176        .map_err(|e| AgentError::Config(format!("failed to read worktree dir: {e}")))?;
177
178    for entry in entries {
179        let entry = match entry {
180            Ok(e) => e,
181            Err(_) => continue,
182        };
183
184        let path = entry.path();
185        if !path.is_dir() {
186            continue;
187        }
188
189        // Skip if dirty.
190        match has_worktree_changes(&path) {
191            Ok(true) => continue, // Has changes — don't clean up.
192            Ok(false) => {}
193            Err(_) => continue,
194        }
195
196        // Safe to remove.
197        if let Ok(()) = remove_subagent_worktree(&path, false) {
198            cleaned.push(path);
199        }
200    }
201
202    Ok(cleaned)
203}
204
205/// Find the canonical (top-level) git root, traversing through nested worktrees.
206///
207/// Unlike `find_git_root` which stops at the first `.git`, this function
208/// follows `.git` files (which are used by git worktrees to point back to
209/// the main repo) to find the *real* top-level repo. This prevents creating
210/// nested worktrees inside existing worktrees.
211fn find_canonical_git_root(cwd: &Path) -> Result<PathBuf, AgentError> {
212    let mut current = cwd.to_path_buf();
213
214    // First, find any git root (could be a worktree).
215    let first_root = loop {
216        let git_path = current.join(".git");
217        if git_path.exists() {
218            break current.clone();
219        }
220        if !current.pop() {
221            return Err(AgentError::Config(format!(
222                "not inside a git repository: {}",
223                cwd.display()
224            )));
225        }
226    };
227
228    // Read .git to see if it's a worktree (pointer file) or regular repo dir.
229    let git_entry = first_root.join(".git");
230    if git_entry.is_file()
231        && let Ok(content) = std::fs::read_to_string(&git_entry)
232    {
233        // Format: "gitdir: /path/to/main/.git/worktrees/name"
234        if let Some(gitdir_line) = content.strip_prefix("gitdir: ") {
235            let gitdir_path = PathBuf::from(gitdir_line.trim());
236            // The real .git dir is at `<gitdir>/../../` (parent of the worktrees dir)
237            if let Some(worktrees_dir) = gitdir_path.parent()
238                && let Some(git_dir) = worktrees_dir.parent()
239                && let Some(main_repo) = git_dir.parent()
240            {
241                return Ok(main_repo.to_path_buf());
242            }
243        }
244    }
245
246    // Not a worktree — this is the canonical root.
247    Ok(first_root)
248}
249
250/// Validate a slug for use in worktree paths and branch names.
251///
252/// Rules:
253/// - Max 64 characters total
254/// - Each `/`-separated segment must contain only `[a-zA-Z0-9._-]`
255/// - Segments must not be `.` or `..`
256/// - Segments must not be empty
257pub fn validate_slug(slug: &str) -> Result<(), AgentError> {
258    if slug.is_empty() {
259        return Err(AgentError::Config("slug must not be empty".into()));
260    }
261    if slug.len() > 64 {
262        return Err(AgentError::Config(format!("slug too long: max 64 chars, got {}", slug.len())));
263    }
264
265    for segment in slug.split('/') {
266        if segment.is_empty() {
267            return Err(AgentError::Config("slug contains an empty segment".into()));
268        }
269        if segment == "." || segment == ".." {
270            return Err(AgentError::Config(format!("slug contains invalid segment: '{segment}'")));
271        }
272        if !segment.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') {
273            return Err(AgentError::Config(format!(
274                "slug segment '{segment}' contains invalid characters (allowed: a-z, A-Z, 0-9, ., _, -)"
275            )));
276        }
277    }
278
279    Ok(())
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn valid_slugs() {
288        assert!(validate_slug("explore-agent").is_ok());
289        assert!(validate_slug("agent-001").is_ok());
290        assert!(validate_slug("team/lead").is_ok());
291        assert!(validate_slug("a").is_ok());
292        assert!(validate_slug("abc.def_ghi-jkl").is_ok());
293    }
294
295    #[test]
296    fn invalid_slugs() {
297        assert!(validate_slug("").is_err());
298        assert!(validate_slug(".").is_err());
299        assert!(validate_slug("..").is_err());
300        assert!(validate_slug("a/../b").is_err());
301        assert!(validate_slug("has space").is_err());
302        assert!(validate_slug("has/slash/at/end/").is_err());
303        assert!(validate_slug("/starts-with-slash").is_err());
304    }
305
306    #[test]
307    fn slug_max_length() {
308        assert!(validate_slug(&"a".repeat(64)).is_ok());
309        assert!(validate_slug(&"a".repeat(65)).is_err());
310    }
311}