返回 CodeWhale
worktree.rs
根目录 / crates / tui / src / tools / subagent / worktree.rs
1 //! Workspace validation and first-class git worktree isolation for sub-agents.
2
3 use std::fs;
4 use std::path::{Path, PathBuf};
5
6 use uuid::Uuid;
7
8 use crate::dependencies::{ExternalTool, Git};
9 use crate::tools::spec::ToolError;
10
11 use super::FleetRole;
12
13 const SUBAGENT_WORKTREE_ROOT_DIR: &str = ".codewhale-worktrees";
14
15 #[derive(Debug, Clone, PartialEq, Eq)]
16 pub(super) struct SubAgentWorktreeRequest {
17 pub(super) branch: Option<String>,
18 pub(super) path: Option<PathBuf>,
19 pub(super) base_ref: Option<String>,
20 }
21
22 pub(super) fn prepare_child_workspace(
23 parent_workspace: &Path,
24 requested_cwd: Option<&Path>,
25 worktree: Option<&SubAgentWorktreeRequest>,
26 session_name: Option<&str>,
27 agent_type: &FleetRole,
28 ) -> Result<Option<PathBuf>, ToolError> {
29 let discovery_anchor = if let Some(requested_cwd) = requested_cwd {
30 validate_existing_child_cwd(parent_workspace, requested_cwd)?
31 } else {
32 parent_workspace
33 .canonicalize()
34 .unwrap_or_else(|_| parent_workspace.to_path_buf())
35 };
36
37 if let Some(worktree) = worktree {
38 return create_isolated_worktree(&discovery_anchor, worktree, session_name, agent_type)
39 .map(Some);
40 }
41
42 if requested_cwd.is_some() {
43 return Ok(Some(discovery_anchor));
44 }
45
46 Ok(None)
47 }
48
49 fn validate_existing_child_cwd(
50 parent_workspace: &Path,
51 requested_cwd: &Path,
52 ) -> Result<PathBuf, ToolError> {
53 let resolved = if requested_cwd.is_absolute() {
54 requested_cwd.to_path_buf()
55 } else {
56 parent_workspace.join(requested_cwd)
57 };
58 let canonical = resolved.canonicalize().map_err(|e| {
59 ToolError::invalid_input(format!(
60 "Invalid cwd '{}': {e} (path may not exist yet — use worktree=true to let Codewhale create an isolated checkout)",
61 requested_cwd.display()
62 ))
63 })?;
64 let workspace_canonical = parent_workspace
65 .canonicalize()
66 .unwrap_or_else(|_| parent_workspace.to_path_buf());
67 if !canonical.starts_with(&workspace_canonical) {
68 return Err(ToolError::invalid_input(format!(
69 "cwd must be inside the parent workspace: {} is not under {}",
70 canonical.display(),
71 workspace_canonical.display()
72 )));
73 }
74 Ok(canonical)
75 }
76
77 pub(super) fn create_isolated_worktree(
78 parent_workspace: &Path,
79 request: &SubAgentWorktreeRequest,
80 session_name: Option<&str>,
81 agent_type: &FleetRole,
82 ) -> Result<PathBuf, ToolError> {
83 let repo_root = git_repo_root(parent_workspace)?;
84 let branch = request
85 .branch
86 .clone()
87 .unwrap_or_else(|| default_worktree_branch(session_name, agent_type));
88 validate_git_branch_name(&repo_root, &branch)?;
89
90 let base_ref = request
91 .base_ref
92 .as_deref()
93 .map(str::trim)
94 .filter(|value| !value.is_empty())
95 .unwrap_or("HEAD")
96 .to_string();
97 let worktree_path = resolve_worktree_path(&repo_root, &branch, request.path.as_ref())?;
98 if let Some(parent) = worktree_path.parent() {
99 fs::create_dir_all(parent).map_err(|err| {
100 ToolError::execution_failed(format!(
101 "Failed to create worktree parent '{}': {err}",
102 parent.display()
103 ))
104 })?;
105 }
106
107 let path_arg = worktree_path.to_string_lossy().to_string();
108 let args = vec![
109 "worktree".to_string(),
110 "add".to_string(),
111 "-b".to_string(),
112 branch,
113 path_arg,
114 base_ref,
115 ];
116 run_git_checked(&repo_root, &args, "create sub-agent worktree")?;
117 worktree_path.canonicalize().map_err(|err| {
118 ToolError::execution_failed(format!(
119 "Created worktree path '{}' could not be resolved: {err}",
120 worktree_path.display()
121 ))
122 })
123 }
124
125 pub(super) fn git_repo_root(workspace: &Path) -> Result<PathBuf, ToolError> {
126 const MAX_PARENT_LEVELS: usize = 4;
127 let start = workspace
128 .canonicalize()
129 .unwrap_or_else(|_| workspace.to_path_buf());
130 let mut paths_tried = Vec::new();
131 let mut current = Some(start.as_path());
132 let mut levels = 0usize;
133
134 while let Some(dir) = current {
135 paths_tried.push(dir.display().to_string());
136
137 if let Some(root) = try_git_toplevel(dir) {
138 return Ok(root);
139 }
140
141 if let Ok(entries) = fs::read_dir(dir) {
142 let mut nested_roots = Vec::new();
143 for entry in entries.flatten() {
144 let child = entry.path();
145 if !child.is_dir() || !path_looks_like_git_checkout(&child) {
146 continue;
147 }
148 if child
149 .file_name()
150 .and_then(|name| name.to_str())
151 .is_some_and(|name| name.starts_with('.'))
152 {
153 continue;
154 }
155 if let Some(root) = try_git_toplevel(&child) {
156 nested_roots.push(root);
157 }
158 }
159 match nested_roots.len() {
160 0 => {}
161 1 => return Ok(nested_roots.into_iter().next().expect("single nested root")),
162 _ => {
163 let repos = nested_roots
164 .iter()
165 .map(|path| path.display().to_string())
166 .collect::<Vec<_>>()
167 .join(", ");
168 return Err(ToolError::invalid_input(format!(
169 "Multiple git repositories found under {}. Specify cwd to disambiguate: {repos}",
170 dir.display()
171 )));
172 }
173 }
174 }
175
176 levels += 1;
177 if levels > MAX_PARENT_LEVELS {
178 break;
179 }
180 current = dir.parent();
181 }
182
183 Err(ToolError::invalid_input(format!(
184 "worktree=true requires a git repository. Tried: {}",
185 paths_tried.join(", ")
186 )))
187 }
188
189 fn path_looks_like_git_checkout(path: &Path) -> bool {
190 let git_path = path.join(".git");
191 git_path.is_dir() || git_path.is_file()
192 }
193
194 fn try_git_toplevel(path: &Path) -> Option<PathBuf> {
195 let output = Git::output(&["rev-parse", "--show-toplevel"], path).ok()?;
196 if !output.status.success() {
197 return None;
198 }
199 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
200 if root.is_empty() {
201 None
202 } else {
203 Some(PathBuf::from(root))
204 }
205 }
206
207 fn validate_git_branch_name(repo_root: &Path, branch: &str) -> Result<(), ToolError> {
208 let branch = branch.trim();
209 if branch.is_empty() {
210 return Err(ToolError::invalid_input(
211 "worktree_branch cannot be blank".to_string(),
212 ));
213 }
214 run_git_checked(
215 repo_root,
216 &[
217 "check-ref-format".to_string(),
218 "--branch".to_string(),
219 branch.to_string(),
220 ],
221 "validate sub-agent worktree branch",
222 )
223 .map(|_| ())
224 .map_err(|err| ToolError::invalid_input(format!("Invalid worktree_branch '{branch}': {err}")))
225 }
226
227 fn default_worktree_branch(session_name: Option<&str>, agent_type: &FleetRole) -> String {
228 let seed = session_name
229 .map(str::trim)
230 .filter(|name| !name.is_empty())
231 .unwrap_or_else(|| agent_type.as_str());
232 format!(
233 "codex/agent-{}-{}",
234 sanitize_worktree_slug(seed),
235 &Uuid::new_v4().to_string()[..8]
236 )
237 }
238
239 fn resolve_worktree_path(
240 repo_root: &Path,
241 branch: &str,
242 requested_path: Option<&PathBuf>,
243 ) -> Result<PathBuf, ToolError> {
244 let default_root = default_worktree_root(repo_root);
245 let path = match requested_path {
246 Some(path) if path.is_absolute() => path.to_path_buf(),
247 Some(path) => {
248 let resolved = normalize_path_lexically(&default_root.join(path));
249 if !resolved.starts_with(&default_root) {
250 return Err(ToolError::invalid_input(format!(
251 "relative worktree_path '{}' must stay under {}",
252 path.display(),
253 default_root.display()
254 )));
255 }
256 resolved
257 }
258 None => default_root.join(sanitize_worktree_slug(branch)),
259 };
260 let normalized = normalize_path_lexically(&path);
261 let repo_canonical = repo_root
262 .canonicalize()
263 .unwrap_or_else(|_| repo_root.to_path_buf());
264 if normalized.starts_with(&repo_canonical) {
265 return Err(ToolError::invalid_input(format!(
266 "worktree_path must not be inside the parent checkout: {} is under {}",
267 normalized.display(),
268 repo_canonical.display()
269 )));
270 }
271 Ok(normalized)
272 }
273
274 fn default_worktree_root(repo_root: &Path) -> PathBuf {
275 let repo_name = repo_root
276 .file_name()
277 .and_then(|name| name.to_str())
278 .map(sanitize_worktree_slug)
279 .filter(|name| !name.is_empty())
280 .unwrap_or_else(|| "repo".to_string());
281 let parent = repo_root.parent().unwrap_or(repo_root);
282 normalize_path_lexically(&parent.join(SUBAGENT_WORKTREE_ROOT_DIR).join(repo_name))
283 }
284
285 fn sanitize_worktree_slug(input: &str) -> String {
286 let mut slug = String::new();
287 for ch in input.chars() {
288 let normalized = if ch.is_ascii_alphanumeric() {
289 ch.to_ascii_lowercase()
290 } else if matches!(ch, '-' | '_' | '.') {
291 ch
292 } else {
293 '-'
294 };
295 if normalized == '-' && slug.ends_with('-') {
296 continue;
297 }
298 slug.push(normalized);
299 if slug.len() >= 48 {
300 break;
301 }
302 }
303 let slug = slug.trim_matches(['-', '.', '_']).to_string();
304 if slug.is_empty() {
305 "task".to_string()
306 } else {
307 slug
308 }
309 }
310
311 fn normalize_path_lexically(path: &Path) -> PathBuf {
312 let mut normalized = PathBuf::new();
313 for component in path.components() {
314 match component {
315 std::path::Component::CurDir => {}
316 std::path::Component::ParentDir => {
317 normalized.pop();
318 }
319 other => normalized.push(other.as_os_str()),
320 }
321 }
322 normalized
323 }
324
325 fn run_git_checked(workspace: &Path, args: &[String], action: &str) -> Result<String, ToolError> {
326 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
327 let output = Git::output(&arg_refs, workspace).map_err(|err| {
328 ToolError::execution_failed(format!("Failed to {action}: could not run git: {err}"))
329 })?;
330 if output.status.success() {
331 return Ok(String::from_utf8_lossy(&output.stdout).to_string());
332 }
333 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
334 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
335 let detail = if !stderr.is_empty() {
336 stderr
337 } else if !stdout.is_empty() {
338 stdout
339 } else {
340 format!("git exited with status {}", output.status)
341 };
342 Err(ToolError::execution_failed(format!(
343 "Failed to {action}: {detail}"
344 )))
345 }
346
346 lines RUST