返回 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 workspace_canonical = parent_workspace
59 .canonicalize()
60 .unwrap_or_else(|_| parent_workspace.to_path_buf());
61 let canonical = resolved.canonicalize().map_err(|e| {
62 ToolError::invalid_input(format!(
63 "Invalid cwd '{}': {e}. Allowed cwd: an existing directory under {} (relative paths resolve against it), or omit cwd to use it. The path may not exist yet — use worktree=true to let Codewhale create an isolated checkout.",
64 requested_cwd.display(),
65 workspace_canonical.display()
66 ))
67 })?;
68 if !canonical.starts_with(&workspace_canonical) {
69 return Err(ToolError::invalid_input(format!(
70 "cwd must be inside the parent workspace: {} is not under {}. Allowed cwd: {} or a directory beneath it (relative paths resolve against it); omit cwd to run the child there.",
71 canonical.display(),
72 workspace_canonical.display(),
73 workspace_canonical.display()
74 )));
75 }
76 Ok(canonical)
77 }
78
79 pub(super) fn create_isolated_worktree(
80 parent_workspace: &Path,
81 request: &SubAgentWorktreeRequest,
82 session_name: Option<&str>,
83 agent_type: &FleetRole,
84 ) -> Result<PathBuf, ToolError> {
85 let repo_root = git_repo_root(parent_workspace)?;
86 let branch = request
87 .branch
88 .clone()
89 .unwrap_or_else(|| default_worktree_branch(session_name, agent_type));
90 validate_git_branch_name(&repo_root, &branch)?;
91
92 let base_ref = request
93 .base_ref
94 .as_deref()
95 .map(str::trim)
96 .filter(|value| !value.is_empty())
97 .unwrap_or("HEAD")
98 .to_string();
99 if base_ref.starts_with('-') {
100 return Err(ToolError::invalid_input(format!(
101 "Invalid worktree_base '{base_ref}': a ref cannot start with '-'"
102 )));
103 }
104 let worktree_path = resolve_worktree_path(&repo_root, &branch, request.path.as_ref())?;
105 // One worktree implementation: the Runtime lane's (#4176). It creates the
106 // parent directory and captures git output instead of inheriting the TUI.
107 codewhale_lane::provision_worktree(&codewhale_lane::WorktreeProvision {
108 repo_root: repo_root.clone(),
109 branch,
110 path: worktree_path.clone(),
111 base_ref: Some(base_ref),
112 })
113 .map_err(|err| {
114 ToolError::execution_failed(format!("Failed to create sub-agent worktree: {err:#}"))
115 })?;
116 worktree_path.canonicalize().map_err(|err| {
117 ToolError::execution_failed(format!(
118 "Created worktree path '{}' could not be resolved: {err}",
119 worktree_path.display()
120 ))
121 })
122 }
123
124 /// Remove a finished worker's isolated worktree (and its merged branch) when
125 /// the worker changed nothing in it. `changed` is the delivery-evidence
126 /// inventory: `None` means git could not answer, and nothing is removed.
127 /// Removal goes through the lane implementation, which only ever deletes a
128 /// path git itself lists as a linked worktree (#5824).
129 pub(super) fn remove_unchanged_worktree(
130 worktree: &Path,
131 changed: Option<&std::collections::BTreeSet<String>>,
132 ) -> bool {
133 if !changed.is_some_and(std::collections::BTreeSet::is_empty) || !worktree.exists() {
134 return false;
135 }
136 // The delivery inventory sees tracked and untracked paths but not ignored
137 // ones, and removal is forced. A fresh worktree holds no ignored files, so
138 // any (a report, a build output) was written by the worker: keep it.
139 let pristine = Git::output(
140 &[
141 "status",
142 "--porcelain",
143 "--ignored",
144 "--untracked-files=all",
145 "-z",
146 ],
147 worktree,
148 )
149 .is_ok_and(|output| output.status.success() && output.stdout.is_empty());
150 if !pristine {
151 return false;
152 }
153 if let Err(err) = codewhale_lane::remove_worktree_if_expired(worktree, Some(0), None) {
154 tracing::debug!(
155 "kept unchanged sub-agent worktree {}: {err:#}",
156 worktree.display()
157 );
158 }
159 !worktree.exists()
160 }
161
162 /// Remove the isolated worktree (and its new branch) of a spawn refused
163 /// before its child started. No agent ever ran in it, so everything it holds
164 /// came from the checkout itself, including what post-checkout hooks,
165 /// line-ending filters or LFS wrote there. Requiring a pristine
166 /// `git status`, as [`remove_unchanged_worktree`] does for a finished
167 /// worker, kept exactly those checkouts behind. The removal still goes
168 /// through the lane implementation, which deletes only a path git lists as a
169 /// linked worktree, and the branch only when merged.
170 pub(super) fn remove_unstarted_worktree(worktree: &Path) {
171 let outcome = codewhale_lane::remove_worktree_if_expired(worktree, Some(0), None);
172 if outcome.is_err() || worktree.exists() {
173 tracing::warn!(
174 "could not remove the worktree of a refused sub-agent spawn {}: {}",
175 worktree.display(),
176 outcome.err().map_or_else(
177 || "git did not list it as a linked worktree".to_string(),
178 |err| format!("{err:#}")
179 )
180 );
181 }
182 }
183
184 pub(super) fn git_repo_root(workspace: &Path) -> Result<PathBuf, ToolError> {
185 const MAX_PARENT_LEVELS: usize = 4;
186 let start = workspace
187 .canonicalize()
188 .unwrap_or_else(|_| workspace.to_path_buf());
189 let mut paths_tried = Vec::new();
190 let mut current = Some(start.as_path());
191 let mut levels = 0usize;
192
193 while let Some(dir) = current {
194 paths_tried.push(dir.display().to_string());
195
196 if let Some(root) = try_git_toplevel(dir) {
197 return Ok(root);
198 }
199
200 if let Ok(entries) = fs::read_dir(dir) {
201 let mut nested_roots = Vec::new();
202 for entry in entries.flatten() {
203 let child = entry.path();
204 if !child.is_dir() || !path_looks_like_git_checkout(&child) {
205 continue;
206 }
207 if child
208 .file_name()
209 .and_then(|name| name.to_str())
210 .is_some_and(|name| name.starts_with('.'))
211 {
212 continue;
213 }
214 if let Some(root) = try_git_toplevel(&child) {
215 nested_roots.push(root);
216 }
217 }
218 match nested_roots.len() {
219 0 => {}
220 1 => return Ok(nested_roots.into_iter().next().expect("single nested root")),
221 _ => {
222 let repos = nested_roots
223 .iter()
224 .map(|path| path.display().to_string())
225 .collect::<Vec<_>>()
226 .join(", ");
227 return Err(ToolError::invalid_input(format!(
228 "Multiple git repositories found under {}. Specify cwd to disambiguate: {repos}",
229 dir.display()
230 )));
231 }
232 }
233 }
234
235 levels += 1;
236 if levels > MAX_PARENT_LEVELS {
237 break;
238 }
239 current = dir.parent();
240 }
241
242 Err(ToolError::invalid_input(format!(
243 "worktree=true requires a git repository. Tried: {}",
244 paths_tried.join(", ")
245 )))
246 }
247
248 fn path_looks_like_git_checkout(path: &Path) -> bool {
249 let git_path = path.join(".git");
250 git_path.is_dir() || git_path.is_file()
251 }
252
253 fn try_git_toplevel(path: &Path) -> Option<PathBuf> {
254 let output = Git::output(&["rev-parse", "--show-toplevel"], path).ok()?;
255 if !output.status.success() {
256 return None;
257 }
258 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
259 if root.is_empty() {
260 None
261 } else {
262 Some(PathBuf::from(root))
263 }
264 }
265
266 fn validate_git_branch_name(repo_root: &Path, branch: &str) -> Result<(), ToolError> {
267 let branch = branch.trim();
268 if branch.is_empty() {
269 return Err(ToolError::invalid_input(
270 "worktree_branch cannot be blank".to_string(),
271 ));
272 }
273 run_git_checked(
274 repo_root,
275 &[
276 "check-ref-format".to_string(),
277 "--branch".to_string(),
278 branch.to_string(),
279 ],
280 "validate sub-agent worktree branch",
281 )
282 .map(|_| ())
283 .map_err(|err| ToolError::invalid_input(format!("Invalid worktree_branch '{branch}': {err}")))
284 }
285
286 /// Longest seed slug a default branch carries. The default checkout path is
287 /// the branch slug, which [`sanitize_worktree_slug`] caps at 48 characters;
288 /// `codex/agent-` (12) + seed + `-` + an 8-character unique suffix must fit,
289 /// or a long agent name truncated the suffix away and every retry of that
290 /// name (or any name sharing its first 27 characters) reused one path.
291 const DEFAULT_BRANCH_SEED_MAX: usize = 27;
292
293 fn default_worktree_branch(session_name: Option<&str>, agent_type: &FleetRole) -> String {
294 let seed = session_name
295 .map(str::trim)
296 .filter(|name| !name.is_empty())
297 .unwrap_or_else(|| agent_type.as_str());
298 // The slug is ASCII, so truncating by bytes cannot split a character.
299 let mut seed = sanitize_worktree_slug(seed);
300 seed.truncate(DEFAULT_BRANCH_SEED_MAX);
301 let seed = seed.trim_end_matches(['-', '.', '_']);
302 format!(
303 "codex/agent-{}-{}",
304 if seed.is_empty() { "task" } else { seed },
305 &Uuid::new_v4().to_string()[..8]
306 )
307 }
308
309 fn resolve_worktree_path(
310 repo_root: &Path,
311 branch: &str,
312 requested_path: Option<&PathBuf>,
313 ) -> Result<PathBuf, ToolError> {
314 let default_root = default_worktree_root(repo_root);
315 let path = match requested_path {
316 // Absolute and relative requests get the same containment: a
317 // sub-agent checkout lives under the per-repo worktree root and
318 // nowhere else.
319 //
320 // The request is rebased onto `default_root` as git reported it, so a
321 // Windows caller spelling the same directory differently (`\\?\`
322 // verbatim prefix from `canonicalize`, `/` separators, drive-letter
323 // case) is compared, and checked out, in one form.
324 Some(path) => {
325 let requested = normalize_path_lexically(&default_root.join(path));
326 match relative_to_root(&requested, &default_root)
327 .map(|rest| normalize_path_lexically(&default_root.join(rest)))
328 .filter(|resolved| worktree_path_within_root(resolved, &default_root))
329 {
330 Some(resolved) => resolved,
331 None => {
332 return Err(ToolError::invalid_input(format!(
333 "worktree_path '{}' must stay under {}",
334 path.display(),
335 default_root.display()
336 )));
337 }
338 }
339 }
340 None => default_root.join(branch_worktree_slug(branch)),
341 };
342 let normalized = normalize_path_lexically(&path);
343 let repo_canonical = repo_root
344 .canonicalize()
345 .unwrap_or_else(|_| repo_root.to_path_buf());
346 if relative_to_root(&normalized, &repo_canonical).is_some() {
347 return Err(ToolError::invalid_input(format!(
348 "worktree_path must not be inside the parent checkout: {} is under {}",
349 normalized.display(),
350 repo_canonical.display()
351 )));
352 }
353 Ok(normalized)
354 }
355
356 /// `candidate` (already lexically normalized) must sit under `root` both as
357 /// written and after resolving symlinks in its nearest existing ancestor, so
358 /// a symlink planted under the worktree root cannot redirect the checkout.
359 fn worktree_path_within_root(candidate: &Path, root: &Path) -> bool {
360 candidate.starts_with(root)
361 && canonicalize_existing_prefix(candidate).starts_with(canonicalize_existing_prefix(root))
362 }
363
364 /// The part of `candidate` below `root`, or `None` when it is not under it.
365 /// On Windows the two may spell one directory differently, so they are
366 /// compared in a simplified, case-insensitive form.
367 fn relative_to_root(candidate: &Path, root: &Path) -> Option<PathBuf> {
368 #[cfg(windows)]
369 {
370 windows_relative_to_root(&candidate.to_string_lossy(), &root.to_string_lossy())
371 .map(PathBuf::from)
372 }
373 #[cfg(not(windows))]
374 {
375 candidate.strip_prefix(root).ok().map(Path::to_path_buf)
376 }
377 }
378
379 /// String form of [`relative_to_root`] for Windows paths: `/` and `\` are one
380 /// separator, the `\\?\` and `\\?\UNC\` verbatim prefixes are dropped, and
381 /// ASCII case is ignored. The match must end on a component boundary, and a
382 /// remainder with a `..` component is refused.
383 #[cfg(any(windows, test))]
384 fn windows_relative_to_root(candidate: &str, root: &str) -> Option<String> {
385 fn simplify(path: &str) -> String {
386 let mut simple = path.replace('/', "\\");
387 if let Some(rest) = simple.strip_prefix(r"\\?\UNC\") {
388 simple = format!(r"\\{rest}");
389 } else if let Some(rest) = simple.strip_prefix(r"\\?\") {
390 simple = rest.to_string();
391 }
392 while simple.len() > 3 && simple.ends_with('\\') {
393 simple.pop();
394 }
395 simple
396 }
397 let candidate = simplify(candidate);
398 let root = simplify(root);
399 if !candidate
400 .get(..root.len())
401 .is_some_and(|head| head.eq_ignore_ascii_case(&root))
402 {
403 return None;
404 }
405 let rest = &candidate[root.len()..];
406 if !(rest.is_empty() || root.ends_with('\\') || rest.starts_with('\\')) {
407 return None;
408 }
409 let rest = rest.trim_start_matches('\\');
410 if rest.split('\\').any(|part| part == "..") {
411 return None;
412 }
413 Some(rest.to_string())
414 }
415
416 /// Canonicalize the deepest existing ancestor of `path` and re-append the
417 /// components that do not exist yet.
418 fn canonicalize_existing_prefix(path: &Path) -> PathBuf {
419 let mut missing = Vec::new();
420 let mut cursor = path;
421 loop {
422 if let Ok(canonical) = cursor.canonicalize() {
423 let mut resolved = canonical;
424 for name in missing.iter().rev() {
425 resolved.push(name);
426 }
427 return resolved;
428 }
429 match (cursor.file_name(), cursor.parent()) {
430 (Some(name), Some(parent)) => {
431 missing.push(name.to_os_string());
432 cursor = parent;
433 }
434 _ => return path.to_path_buf(),
435 }
436 }
437 }
438
439 fn default_worktree_root(repo_root: &Path) -> PathBuf {
440 let repo_name = repo_root
441 .file_name()
442 .and_then(|name| name.to_str())
443 .map(sanitize_worktree_slug)
444 .filter(|name| !name.is_empty())
445 .unwrap_or_else(|| "repo".to_string());
446 let parent = repo_root.parent().unwrap_or(repo_root);
447 normalize_path_lexically(&parent.join(SUBAGENT_WORKTREE_ROOT_DIR).join(repo_name))
448 }
449
450 /// The default checkout directory for `branch`: its slug, or, when the slug
451 /// had to be cut to [`WORKTREE_SLUG_MAX`], the cut slug with a short hash of
452 /// the whole branch name, so two long branches sharing a prefix get their own
453 /// checkouts instead of the second spawn failing on an existing path.
454 fn branch_worktree_slug(branch: &str) -> String {
455 let slug = sanitize_worktree_slug(branch);
456 if slug == sanitize_worktree_slug_within(branch, usize::MAX) {
457 return slug;
458 }
459 use sha2::{Digest, Sha256};
460 let digest = Sha256::digest(branch.as_bytes());
461 let suffix: String = digest[..4]
462 .iter()
463 .map(|byte| format!("{byte:02x}"))
464 .collect();
465 // The slug is ASCII, so cutting by bytes cannot split a character.
466 let mut prefix = slug;
467 prefix.truncate(WORKTREE_SLUG_MAX - suffix.len() - 1);
468 let prefix = prefix.trim_end_matches(['-', '.', '_']);
469 format!("{prefix}-{suffix}")
470 }
471
472 /// Longest sub-agent worktree path slug.
473 const WORKTREE_SLUG_MAX: usize = 48;
474
475 fn sanitize_worktree_slug(input: &str) -> String {
476 sanitize_worktree_slug_within(input, WORKTREE_SLUG_MAX)
477 }
478
479 fn sanitize_worktree_slug_within(input: &str, max: usize) -> String {
480 let mut slug = String::new();
481 for ch in input.chars() {
482 let normalized = if ch.is_ascii_alphanumeric() {
483 ch.to_ascii_lowercase()
484 } else if matches!(ch, '-' | '_' | '.') {
485 ch
486 } else {
487 '-'
488 };
489 if normalized == '-' && slug.ends_with('-') {
490 continue;
491 }
492 slug.push(normalized);
493 if slug.len() >= max {
494 break;
495 }
496 }
497 let slug = slug.trim_matches(['-', '.', '_']).to_string();
498 if slug.is_empty() {
499 "task".to_string()
500 } else {
501 slug
502 }
503 }
504
505 fn normalize_path_lexically(path: &Path) -> PathBuf {
506 let mut normalized = PathBuf::new();
507 for component in path.components() {
508 match component {
509 std::path::Component::CurDir => {}
510 std::path::Component::ParentDir => {
511 normalized.pop();
512 }
513 other => normalized.push(other.as_os_str()),
514 }
515 }
516 normalized
517 }
518
519 fn run_git_checked(workspace: &Path, args: &[String], action: &str) -> Result<String, ToolError> {
520 let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
521 let output = Git::output(&arg_refs, workspace).map_err(|err| {
522 ToolError::execution_failed(format!("Failed to {action}: could not run git: {err}"))
523 })?;
524 if output.status.success() {
525 return Ok(String::from_utf8_lossy(&output.stdout).to_string());
526 }
527 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
528 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
529 let detail = if !stderr.is_empty() {
530 stderr
531 } else if !stdout.is_empty() {
532 stdout
533 } else {
534 format!("git exited with status {}", output.status)
535 };
536 Err(ToolError::execution_failed(format!(
537 "Failed to {action}: {detail}"
538 )))
539 }
540
541 #[cfg(test)]
542 mod tests {
543 use super::*;
544
545 /// A long agent name used to push the unique suffix past the 48-character
546 /// path slug, so a retry of the same name collided with the checkout the
547 /// previous run kept.
548 #[test]
549 fn default_worktree_paths_stay_unique_for_long_agent_names() {
550 let name = "audit-authentication-middleware-a";
551 let first = default_worktree_branch(Some(name), &FleetRole::Worker);
552 let second = default_worktree_branch(Some(name), &FleetRole::Worker);
553 let sibling = default_worktree_branch(
554 Some("audit-authentication-middleware-b"),
555 &FleetRole::Worker,
556 );
557 assert!(
558 first.starts_with("codex/agent-audit-authentication-midd"),
559 "{first}"
560 );
561 let paths = [&first, &second, &sibling].map(|branch| branch_worktree_slug(branch));
562 for (branch, path) in [&first, &second, &sibling].into_iter().zip(&paths) {
563 assert_eq!(
564 path.as_str(),
565 branch.replace('/', "-"),
566 "the default path slug keeps the whole branch, suffix included"
567 );
568 }
569 assert_ne!(paths[0], paths[1], "a retry must get its own checkout");
570 assert_ne!(
571 paths[0], paths[2],
572 "names sharing a prefix must not collide"
573 );
574 }
575
576 /// An explicit branch with no worktree_path gets its path from the branch
577 /// slug, cut at 48 characters. Two long branches sharing that prefix used
578 /// to map to one checkout, and the second spawn failed on it.
579 #[test]
580 fn long_explicit_branches_sharing_a_prefix_get_distinct_paths() {
581 let a = "feature/very-long-shared-prefix-authentication-work-a";
582 let b = "feature/very-long-shared-prefix-authentication-work-b";
583 let (path_a, path_b) = (branch_worktree_slug(a), branch_worktree_slug(b));
584 assert_ne!(path_a, path_b);
585 for path in [&path_a, &path_b] {
586 assert!(path.len() <= WORKTREE_SLUG_MAX, "{path}");
587 assert!(
588 path.starts_with("feature-very-long-shared-prefix"),
589 "{path}"
590 );
591 }
592 assert_eq!(path_a, branch_worktree_slug(a), "the path is stable");
593 assert_eq!(
594 branch_worktree_slug("feature/short"),
595 "feature-short",
596 "a branch that fits keeps its plain slug"
597 );
598 }
599
600 #[test]
601 fn cwd_errors_name_the_allowed_root() {
602 let workspace = tempfile::tempdir().expect("workspace");
603 let outside = tempfile::tempdir().expect("outside");
604 let root = workspace
605 .path()
606 .canonicalize()
607 .expect("canonical workspace");
608
609 let err = validate_existing_child_cwd(workspace.path(), outside.path())
610 .expect_err("outside cwd refused")
611 .to_string();
612 assert!(err.contains("Allowed cwd"), "{err}");
613 assert!(err.contains(&root.display().to_string()), "{err}");
614
615 let err = validate_existing_child_cwd(workspace.path(), Path::new("missing/dir"))
616 .expect_err("missing cwd refused")
617 .to_string();
618 assert!(err.contains("Allowed cwd"), "{err}");
619 assert!(err.contains(&root.display().to_string()), "{err}");
620
621 std::fs::create_dir(workspace.path().join("sub")).expect("sub");
622 assert_eq!(
623 validate_existing_child_cwd(workspace.path(), Path::new("sub")).expect("inside"),
624 root.join("sub")
625 );
626 }
627
628 #[test]
629 fn windows_paths_under_the_root_match_across_spellings() {
630 let root = r"C:\Users\runner\.codewhale-worktrees\repo";
631 for candidate in [
632 r"\\?\C:\Users\runner\.codewhale-worktrees\repo\inside",
633 r"c:/users/RUNNER/.codewhale-worktrees/repo/inside",
634 r"C:\Users\runner\.codewhale-worktrees\repo\inside\",
635 ] {
636 assert_eq!(
637 windows_relative_to_root(candidate, root).as_deref(),
638 Some("inside"),
639 "{candidate}"
640 );
641 }
642 assert_eq!(
643 windows_relative_to_root(r"\\?\C:\Users\runner\.codewhale-worktrees\repo", root)
644 .as_deref(),
645 Some("")
646 );
647 assert_eq!(
648 windows_relative_to_root(
649 r"\\?\UNC\server\share\wt\repo\a\b",
650 r"\\server/share/wt/repo/"
651 )
652 .as_deref(),
653 Some(r"a\b")
654 );
655 assert_eq!(
656 windows_relative_to_root(r"C:\", r"c:\").as_deref(),
657 Some("")
658 );
659 }
660
661 #[test]
662 fn windows_paths_outside_the_root_are_refused() {
663 let root = r"C:\Users\runner\.codewhale-worktrees\repo";
664 for candidate in [
665 r"C:\Users\runner\.codewhale-worktrees\repo-evil\x",
666 r"D:\Users\runner\.codewhale-worktrees\repo\x",
667 r"\\?\C:\Users\runner\.codewhale-worktrees",
668 r"C:\Users\runner\.codewhale-worktrees\repo\..\escaped",
669 r"C:\Temp\escaped",
670 ] {
671 assert_eq!(
672 windows_relative_to_root(candidate, root),
673 None,
674 "{candidate}"
675 );
676 }
677 }
678 }
679
679 lines RUST