telos_agent/orchestration/subagent/
worktree.rs1#[cfg(windows)]
9use std::os::windows::process::CommandExt;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use crate::error::AgentError;
14
15#[derive(Debug, Clone)]
17pub struct WorktreeInfo {
18 pub path: PathBuf,
20 pub branch: Option<String>,
22 pub was_existing: bool,
24}
25
26pub fn create_subagent_worktree(
32 parent_cwd: &Path,
33 agent_id: &str,
34) -> Result<WorktreeInfo, AgentError> {
35 validate_slug(agent_id)?;
37
38 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 if worktree_dir.exists() {
45 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 let _ = std::fs::remove_dir_all(&worktree_dir);
54 }
55
56 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 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
88pub fn remove_subagent_worktree(
95 worktree_path: &Path,
96 safety_check: bool,
97) -> Result<(), AgentError> {
98 if !worktree_path.exists() {
99 return Ok(()); }
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 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 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 let _ = hidden_command("git").args(["worktree", "prune"]).output();
132
133 Ok(())
134}
135
136pub 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
160pub 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 match has_worktree_changes(&path) {
191 Ok(true) => continue, Ok(false) => {}
193 Err(_) => continue,
194 }
195
196 if let Ok(()) = remove_subagent_worktree(&path, false) {
198 cleaned.push(path);
199 }
200 }
201
202 Ok(cleaned)
203}
204
205fn find_canonical_git_root(cwd: &Path) -> Result<PathBuf, AgentError> {
212 let mut current = cwd.to_path_buf();
213
214 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 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 if let Some(gitdir_line) = content.strip_prefix("gitdir: ") {
235 let gitdir_path = PathBuf::from(gitdir_line.trim());
236 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 Ok(first_root)
248}
249
250pub 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}