1use anyhow::{Context, Result, anyhow};
8use fs2::FileExt;
9use serde::{Deserialize, Serialize};
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use vtcode_commons::{canonicalize, fs::bound_file};
15
16const WORKTREES_DIR_NAME: &str = "worktrees";
17
18#[cfg(unix)]
24pub(super) fn git_command_at(directory: &Path) -> Result<(std::fs::File, Command)> {
25 let handle = bound_file::open_directory_handle(directory)
26 .with_context(|| format!("open handle-bound Git directory {}", directory.display()))?;
27 let mut command = Command::new("git");
28 bound_file::set_command_working_directory(&mut command, &handle)
29 .with_context(|| format!("bind Git cwd to {}", directory.display()))?;
30 Ok((handle, command))
31}
32
33#[cfg(not(unix))]
34pub(super) fn git_command_at(directory: &Path) -> Result<((), Command)> {
35 let metadata = std::fs::symlink_metadata(directory)
36 .with_context(|| format!("inspect validated Git directory {}", directory.display()))?;
37 if metadata.file_type().is_symlink() || !metadata.is_dir() {
38 return Err(anyhow!("Git directory is not a regular directory: {}", directory.display()));
39 }
40
41 let mut command = Command::new("git");
42 command.current_dir(directory);
43 Ok(((), command))
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct WorktreeInfo {
51 pub name: String,
53 pub path: PathBuf,
55 pub head: Option<String>,
57 pub is_dirty: bool,
59}
60
61pub struct WorktreeManager {
67 workspace_root: PathBuf,
68}
69
70impl WorktreeManager {
71 pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
73 Self { workspace_root: workspace_root.into() }
74 }
75
76 pub fn worktrees_dir(&self) -> PathBuf {
78 self.workspace_root.join(".vtcode").join(WORKTREES_DIR_NAME)
79 }
80
81 pub fn create(&self, name: &str) -> Result<PathBuf> {
87 let (_lock, workspace_root) = self.acquire_operation_lock()?;
88 self.create_locked(&workspace_root, name)
89 }
90
91 fn create_locked(&self, workspace_root: &Path, name: &str) -> Result<PathBuf> {
92 let sanitized = sanitize_worktree_name(name);
93 if sanitized.is_empty() {
94 return Err(anyhow!("Worktree name cannot be empty after sanitization"));
95 }
96
97 let worktrees_dir = self.ensure_worktrees_dir_at(workspace_root)?;
98
99 let worktree_path = worktrees_dir.join(&sanitized);
100 match std::fs::symlink_metadata(&worktree_path) {
101 Ok(_) => return Err(anyhow!("Worktree already exists at {}", worktree_path.display())),
102 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
103 Err(error) => {
104 return Err(error).with_context(|| format!("inspect worktree path {}", worktree_path.display()));
105 }
106 }
107
108 bound_file::validate_directory_beneath(workspace_root, Path::new(".vtcode").join(WORKTREES_DIR_NAME).as_path())
109 .with_context(|| format!("revalidate managed worktree directory {}", worktrees_dir.display()))?;
110
111 let branch_name = format!("loop/{sanitized}");
112
113 let (_worktrees_handle, mut command) = git_command_at(&worktrees_dir)?;
114 let output = command
115 .args(["worktree", "add", "-b", &branch_name, &sanitized])
116 .output()
117 .context("Failed to run git worktree add")?;
118
119 if !output.status.success() {
120 let stderr = String::from_utf8_lossy(&output.stderr);
121 return Err(anyhow!("git worktree add failed: {}", stderr.trim()));
122 }
123
124 bound_file::validate_directory_beneath(
125 workspace_root,
126 Path::new(".vtcode").join(WORKTREES_DIR_NAME).join(&sanitized).as_path(),
127 )
128 .with_context(|| format!("verify created worktree {}", worktree_path.display()))?;
129 Ok(worktree_path)
130 }
131
132 pub fn create_from_current(&self, name: &str) -> Result<PathBuf> {
137 let (_lock, workspace_root) = self.acquire_operation_lock()?;
138 let worktree_path = self.create_locked(&workspace_root, name)?;
139 let sanitized = sanitize_worktree_name(name);
140 let relative_worktree = Path::new(".vtcode").join(WORKTREES_DIR_NAME).join(&sanitized);
141 bound_file::validate_directory_beneath(&workspace_root, &relative_worktree)
142 .with_context(|| format!("revalidate created worktree {}", worktree_path.display()))?;
143 let result = self.apply_current_snapshot(&workspace_root, &worktree_path);
144 if let Err(error) = result {
145 if let Err(cleanup_error) = self.remove_locked(&workspace_root, &sanitized) {
146 tracing::debug!(
147 error = %cleanup_error,
148 worktree = %sanitized,
149 "failed to clean up worktree after snapshot failure"
150 );
151 }
152 return Err(error);
153 }
154 Ok(worktree_path)
155 }
156
157 fn apply_current_snapshot(&self, workspace_root: &Path, worktree_path: &Path) -> Result<()> {
158 let relative_worktree = worktree_path
159 .strip_prefix(workspace_root)
160 .with_context(|| format!("worktree path escapes workspace: {}", worktree_path.display()))?;
161 bound_file::validate_directory_beneath(workspace_root, relative_worktree)
162 .with_context(|| format!("validate snapshot worktree {}", worktree_path.display()))?;
163
164 let (_workspace_handle, mut command) = git_command_at(workspace_root)?;
165 let diff = command
166 .args(["diff", "--binary", "HEAD", "--"])
167 .output()
168 .context("Failed to capture current git diff for worktree snapshot")?;
169 if !diff.status.success() {
170 return Err(anyhow!(
171 "git diff failed while preparing worktree snapshot: {}",
172 String::from_utf8_lossy(&diff.stderr).trim()
173 ));
174 }
175
176 if !diff.stdout.is_empty() {
177 bound_file::validate_directory_beneath(workspace_root, relative_worktree)
182 .with_context(|| format!("revalidate snapshot worktree {}", worktree_path.display()))?;
183 let (_worktree_handle, mut command) = git_command_at(worktree_path)?;
184 let mut child = command
185 .args(["apply", "--whitespace=nowarn", "-"])
186 .stdin(Stdio::piped())
187 .stdout(Stdio::null())
188 .stderr(Stdio::piped())
189 .spawn()
190 .context("Failed to apply current git diff to worktree snapshot")?;
191 child
192 .stdin
193 .take()
194 .context("git apply stdin was unavailable")?
195 .write_all(&diff.stdout)
196 .context("Failed to write git diff to worktree snapshot")?;
197 let output = child
198 .wait_with_output()
199 .context("Failed to finish git apply for worktree snapshot")?;
200 if !output.status.success() {
201 return Err(anyhow!(
202 "git apply failed while preparing worktree snapshot: {}",
203 String::from_utf8_lossy(&output.stderr).trim()
204 ));
205 }
206
207 bound_file::validate_directory_beneath(workspace_root, relative_worktree)
208 .with_context(|| format!("verify snapshot worktree {}", worktree_path.display()))?;
209 }
210
211 self.copy_untracked_snapshot_files(workspace_root, worktree_path)
212 }
213
214 #[cfg(unix)]
215 fn copy_untracked_snapshot_files(&self, workspace_root: &Path, worktree_path: &Path) -> Result<()> {
216 let relative_worktree = worktree_path
217 .strip_prefix(workspace_root)
218 .with_context(|| format!("worktree path escapes workspace: {}", worktree_path.display()))?;
219 bound_file::validate_directory_beneath(workspace_root, relative_worktree)
220 .with_context(|| format!("validate snapshot destination {}", worktree_path.display()))?;
221
222 let (_workspace_handle, mut command) = git_command_at(workspace_root)?;
223 let output = command
224 .args(["ls-files", "--others", "--exclude-standard", "-z"])
225 .output()
226 .context("Failed to enumerate untracked files for worktree snapshot")?;
227 if !output.status.success() {
228 return Err(anyhow!(
229 "git ls-files failed while preparing worktree snapshot: {}",
230 String::from_utf8_lossy(&output.stderr).trim()
231 ));
232 }
233
234 for relative_bytes in output.stdout.split(|byte| *byte == 0).filter(|path| !path.is_empty()) {
235 let relative = std::str::from_utf8(relative_bytes).context("untracked file path is not valid UTF-8")?;
236 let relative_path = Path::new(relative);
237 if relative_path.is_absolute()
238 || relative_path
239 .components()
240 .any(|component| matches!(component, std::path::Component::ParentDir))
241 {
242 return Err(anyhow!("untracked file path escapes the workspace: {relative}"));
243 }
244
245 let source = workspace_root.join(relative_path);
246 let metadata = std::fs::symlink_metadata(&source)
247 .with_context(|| format!("stat untracked snapshot file {}", source.display()))?;
248 let destination_relative = relative_worktree.join(relative_path);
249 if metadata.file_type().is_symlink() {
250 let target = std::fs::read_link(&source)
251 .with_context(|| format!("read untracked symlink {}", source.display()))?;
252 bound_file::create_symlink_beneath(workspace_root, &destination_relative, &target)
253 .with_context(|| format!("copy untracked symlink {}", source.display()))?;
254 } else if metadata.is_file() {
255 bound_file::copy_file_beneath(workspace_root, relative_path, workspace_root, &destination_relative)
256 .with_context(|| format!("copy untracked file {}", source.display()))?;
257 }
258 }
259 Ok(())
260 }
261
262 #[cfg(not(unix))]
263 fn copy_untracked_snapshot_files(&self, _workspace_root: &Path, _worktree_path: &Path) -> Result<()> {
264 Err(anyhow!("handle-bound worktree snapshots are unavailable on this platform"))
265 }
266
267 pub fn list(&self) -> Result<Vec<WorktreeInfo>> {
269 let (_lock, workspace_root) = self.acquire_operation_lock()?;
270 self.list_locked(&workspace_root)
271 }
272
273 fn list_locked(&self, workspace_root: &Path) -> Result<Vec<WorktreeInfo>> {
274 let Some(worktrees_dir) = self.validate_worktrees_dir_at(workspace_root)? else {
275 return Ok(Vec::new());
276 };
277
278 let (_workspace_handle, mut command) = git_command_at(workspace_root)?;
279 let output = command
280 .args(["worktree", "list", "--porcelain"])
281 .output()
282 .context("Failed to run git worktree list")?;
283
284 if !output.status.success() {
285 let stderr = String::from_utf8_lossy(&output.stderr);
286 return Err(anyhow!("git worktree list failed: {}", stderr.trim()));
287 }
288
289 let stdout = String::from_utf8_lossy(&output.stdout);
290 let mut worktrees = Vec::new();
291 let mut current_path: Option<PathBuf> = None;
292 let mut current_head: Option<String> = None;
293
294 fn try_build_info(path: PathBuf, head: &mut Option<String>, managed_dir: &Path) -> Option<WorktreeInfo> {
296 if !path.starts_with(managed_dir) {
297 return None;
298 }
299 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();
300 Some(WorktreeInfo { name, path, head: head.take(), is_dirty: false })
301 }
302
303 for line in stdout.lines() {
304 if let Some(rest) = line.strip_prefix("worktree ") {
305 if let Some(path) = current_path.take() {
307 if let Some(info) = try_build_info(path, &mut current_head, &worktrees_dir) {
308 worktrees.push(info);
309 }
310 }
311 current_path = Some(PathBuf::from(rest));
312 current_head = None;
313 } else if let Some(rest) = line.strip_prefix("HEAD ") {
314 current_head = Some(rest.to_string());
315 } else if line == "detached" {
316 }
318 }
319
320 if let Some(path) = current_path {
322 if let Some(info) = try_build_info(path, &mut current_head, &worktrees_dir) {
323 worktrees.push(info);
324 }
325 }
326
327 for wt in &mut worktrees {
329 let relative = wt
330 .path
331 .strip_prefix(workspace_root)
332 .with_context(|| format!("worktree path escapes workspace: {}", wt.path.display()))?;
333 bound_file::validate_directory_beneath(workspace_root, relative)
334 .with_context(|| format!("revalidate listed worktree {}", wt.path.display()))?;
335 let (_worktree_handle, mut command) =
336 git_command_at(&wt.path).with_context(|| format!("open listed worktree {}", wt.path.display()))?;
337 let output = command
338 .args(["status", "--porcelain"])
339 .output()
340 .with_context(|| format!("inspect worktree status {}", wt.path.display()))?;
341 if !output.status.success() {
342 return Err(anyhow!(
343 "git status failed for {}: {}",
344 wt.path.display(),
345 String::from_utf8_lossy(&output.stderr).trim()
346 ));
347 }
348 wt.is_dirty = !output.stdout.is_empty();
349 }
350
351 Ok(worktrees)
352 }
353
354 pub fn remove(&self, name: &str) -> Result<()> {
357 let (_lock, workspace_root) = self.acquire_operation_lock()?;
358 self.remove_locked(&workspace_root, name)
359 }
360
361 fn remove_locked(&self, workspace_root: &Path, name: &str) -> Result<()> {
362 let sanitized = sanitize_worktree_name(name);
363 if sanitized.is_empty() {
364 return Err(anyhow!("Worktree name cannot be empty after sanitization"));
365 }
366 let Some(worktrees_dir) = self.validate_worktrees_dir_at(workspace_root)? else {
367 return Err(anyhow!(
368 "Worktree '{}' does not exist at {}",
369 name,
370 workspace_root
371 .join(".vtcode")
372 .join(WORKTREES_DIR_NAME)
373 .join(&sanitized)
374 .display()
375 ));
376 };
377 let worktree_path = worktrees_dir.join(&sanitized);
378 let relative_worktree = Path::new(".vtcode").join(WORKTREES_DIR_NAME).join(&sanitized);
379 if let Err(error) = bound_file::validate_directory_beneath(workspace_root, &relative_worktree) {
380 if error.kind() == std::io::ErrorKind::NotFound {
381 return Err(anyhow!("Worktree '{}' does not exist at {}", name, worktree_path.display()));
382 }
383 return Err(error).with_context(|| format!("validate worktree path {}", worktree_path.display()));
384 }
385
386 bound_file::validate_directory_beneath(workspace_root, Path::new(".vtcode").join(WORKTREES_DIR_NAME).as_path())
387 .with_context(|| format!("revalidate managed worktree directory {}", worktrees_dir.display()))?;
388
389 let (_worktrees_handle, mut command) = git_command_at(&worktrees_dir)?;
390 let output = command
391 .args(["worktree", "remove", "--force", &sanitized])
392 .output()
393 .context("Failed to run git worktree remove")?;
394
395 if !output.status.success() {
396 let stderr = String::from_utf8_lossy(&output.stderr);
397 return Err(anyhow!("git worktree remove failed: {}", stderr.trim()));
398 }
399
400 let branch = format!("loop/{sanitized}");
402 let (_workspace_handle, mut command) = git_command_at(workspace_root)?;
403 let branch_output = command.args(["branch", "-D", &branch]).output();
404 match branch_output {
405 Ok(o) if !o.status.success() => {
406 tracing::debug!(
409 branch = %branch,
410 stderr = %String::from_utf8_lossy(&o.stderr),
411 "Could not delete orphan branch (may not exist)"
412 );
413 }
414 Err(e) => {
415 tracing::debug!(error = %e, "Failed to spawn git branch -D");
416 }
417 _ => {}
418 }
419
420 Ok(())
421 }
422
423 fn ensure_worktrees_dir_at(&self, workspace_root: &Path) -> Result<PathBuf> {
424 let relative = Path::new(".vtcode").join(WORKTREES_DIR_NAME);
425 bound_file::ensure_directory_beneath(workspace_root, &relative).with_context(|| {
426 format!("create managed worktree directory {}", workspace_root.join(&relative).display())
427 })?;
428 Ok(workspace_root.join(relative))
429 }
430
431 fn acquire_operation_lock(&self) -> Result<(std::fs::File, PathBuf)> {
432 let workspace_root = canonicalize(&self.workspace_root)
433 .with_context(|| format!("canonicalize workspace root {}", self.workspace_root.display()))?;
434 bound_file::ensure_directory_beneath(&workspace_root, Path::new(".vtcode"))
435 .with_context(|| format!("create metadata directory {}", workspace_root.join(".vtcode").display()))?;
436 let lock_path = Path::new(".vtcode").join("worktrees.lock");
437 let lock = bound_file::open_lock_file_beneath(&workspace_root, &lock_path)
438 .with_context(|| format!("open worktree operation lock {}", workspace_root.join(&lock_path).display()))?;
439 lock.lock_exclusive()
440 .with_context(|| format!("lock worktree operation file {}", workspace_root.join(&lock_path).display()))?;
441 Ok((lock, workspace_root))
442 }
443
444 fn validate_worktrees_dir_at(&self, workspace_root: &Path) -> Result<Option<PathBuf>> {
445 let relative = Path::new(".vtcode").join(WORKTREES_DIR_NAME);
446 match bound_file::validate_directory_beneath(workspace_root, &relative) {
447 Ok(()) => Ok(Some(workspace_root.join(relative))),
448 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
449 Err(error) => Err(error).with_context(|| {
450 format!("validate managed worktree directory {}", workspace_root.join(&relative).display())
451 }),
452 }
453 }
454
455 pub fn remove_all(&self) -> Result<usize> {
457 let (_lock, workspace_root) = self.acquire_operation_lock()?;
458 let worktrees = self.list_locked(&workspace_root)?;
459 let count = worktrees.len();
460 for wt in &worktrees {
461 self.remove_locked(&workspace_root, &wt.name)?;
462 }
463 Ok(count)
464 }
465}
466
467fn sanitize_worktree_name(name: &str) -> String {
470 name.chars()
471 .map(|ch| {
472 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
473 ch
474 } else {
475 '-'
476 }
477 })
478 .collect::<String>()
479 .trim_matches('-')
480 .to_string()
481}
482
483#[cfg(test)]
486mod tests {
487 use super::*;
488 use tempfile::TempDir;
489 use vtcode_commons::canonicalize;
490
491 #[test]
492 fn sanitize_worktree_name_basic() {
493 assert_eq!(sanitize_worktree_name("my-loop"), "my-loop");
494 assert_eq!(sanitize_worktree_name("my_loop"), "my_loop");
495 assert_eq!(sanitize_worktree_name("loop123"), "loop123");
496 }
497
498 #[test]
499 fn sanitize_worktree_name_replaces_special_chars() {
500 assert_eq!(sanitize_worktree_name("my loop"), "my-loop");
501 assert_eq!(sanitize_worktree_name("path/to/thing"), "path-to-thing");
502 assert_eq!(sanitize_worktree_name("a@b#c"), "a-b-c");
503 }
504
505 #[test]
506 fn sanitize_worktree_name_trims_dashes() {
507 assert_eq!(sanitize_worktree_name("--test--"), "test");
508 assert_eq!(sanitize_worktree_name(" spaces "), "spaces");
509 }
510
511 #[test]
512 fn sanitize_worktree_name_empty_after_sanitize() {
513 assert_eq!(sanitize_worktree_name("///"), "");
514 assert_eq!(sanitize_worktree_name(""), "");
515 }
516
517 #[test]
518 fn worktree_manager_worktrees_dir() {
519 let mgr = WorktreeManager::new("/tmp/workspace");
520 assert_eq!(mgr.worktrees_dir(), PathBuf::from("/tmp/workspace/.vtcode/worktrees"));
521 }
522
523 use std::process::Command as ProcCommand;
529
530 fn manager_for(repo: &TempDir) -> WorktreeManager {
537 WorktreeManager::new(canonicalize(repo.path()).expect("canonicalize repo"))
538 }
539
540 fn init_temp_git_repo() -> TempDir {
541 let dir = TempDir::new().expect("temp dir");
542 let run = |args: &[&str]| {
543 let status = ProcCommand::new("git")
544 .args(args)
545 .current_dir(dir.path())
546 .output()
547 .expect("spawn git");
548 assert!(status.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&status.stderr));
549 };
550 run(&["init", "-q"]);
551 run(&["config", "user.email", "test@vtcode.dev"]);
552 run(&["config", "user.name", "vtcode-test"]);
553 std::fs::write(dir.path().join("README.md"), "seed\n").expect("write seed");
554 run(&["add", "."]);
555 run(&["commit", "-q", "-m", "seed"]);
556 dir
557 }
558
559 #[cfg(unix)]
560 #[test]
561 fn create_then_list_returns_managed_worktree() {
562 let repo = init_temp_git_repo();
563 let mgr = manager_for(&repo);
564
565 let path = mgr.create("loop-a").expect("create");
566 assert!(path.exists(), "worktree directory must exist after create");
567
568 let worktrees = mgr.list().expect("list");
569 let found = worktrees.iter().find(|w| w.name == "loop-a");
570 assert!(found.is_some(), "created worktree should be listed");
571 assert_eq!(found.unwrap().path, path);
572 }
573
574 #[cfg(unix)]
575 #[test]
576 fn create_is_idempotency_safe() {
577 let repo = init_temp_git_repo();
578 let mgr = manager_for(&repo);
579 mgr.create("loop-b").expect("create first");
580
581 let err = mgr.create("loop-b");
582 assert!(err.is_err(), "re-creating an existing worktree must fail");
583 }
584
585 #[cfg(unix)]
586 #[test]
587 fn create_from_current_carries_tracked_and_untracked_changes() {
588 let repo = init_temp_git_repo();
589 std::fs::write(repo.path().join("README.md"), "working-tree\n").expect("modify tracked file");
590 std::fs::create_dir_all(repo.path().join("nested")).expect("create untracked directory");
591 std::fs::write(repo.path().join("nested").join("fixture.txt"), "untracked\n").expect("write untracked file");
592 let mgr = manager_for(&repo);
593
594 let path = mgr.create_from_current("loop-snapshot").expect("create snapshot");
595 assert_eq!(std::fs::read_to_string(path.join("README.md")).expect("read tracked snapshot"), "working-tree\n");
596 assert_eq!(
597 std::fs::read_to_string(path.join("nested").join("fixture.txt")).expect("read untracked snapshot"),
598 "untracked\n"
599 );
600 mgr.remove("loop-snapshot").expect("remove snapshot");
601 }
602
603 #[cfg(unix)]
604 #[test]
605 fn remove_deletes_worktree_and_orphan_branch() {
606 let repo = init_temp_git_repo();
607 let mgr = manager_for(&repo);
608 mgr.create("loop-c").expect("create");
609
610 mgr.remove("loop-c").expect("remove");
611 assert!(
612 mgr.list().expect("list").iter().all(|w| w.name != "loop-c"),
613 "worktree should no longer be listed after removal"
614 );
615
616 let branch_check = ProcCommand::new("git")
618 .args(["rev-parse", "--verify", "loop/loop-c"])
619 .current_dir(repo.path())
620 .output()
621 .expect("check branch");
622 assert!(!branch_check.status.success(), "orphan branch should be deleted on remove");
623 }
624
625 #[cfg(unix)]
626 #[test]
627 fn remove_missing_worktree_errors() {
628 let repo = init_temp_git_repo();
629 let mgr = manager_for(&repo);
630 assert!(mgr.remove("does-not-exist").is_err(), "removing a non-existent worktree must error");
631 }
632
633 #[cfg(unix)]
634 #[test]
635 fn remove_all_clears_every_managed_worktree() {
636 let repo = init_temp_git_repo();
637 let mgr = manager_for(&repo);
638 mgr.create("loop-x").expect("create x");
639 mgr.create("loop-y").expect("create y");
640
641 let removed = mgr.remove_all().expect("remove_all");
642 assert_eq!(removed, 2);
643 assert!(mgr.list().expect("list").is_empty());
644 }
645
646 #[cfg(unix)]
647 #[test]
648 fn managed_directory_symlink_is_rejected() {
649 use std::os::unix::fs::symlink;
650
651 let repo = init_temp_git_repo();
652 let outside = TempDir::new().expect("outside temp dir");
653 std::fs::create_dir_all(repo.path().join(".vtcode")).expect("create metadata directory");
654 symlink(outside.path(), repo.path().join(".vtcode").join(WORKTREES_DIR_NAME))
655 .expect("create managed directory symlink");
656 let mgr = manager_for(&repo);
657
658 assert!(mgr.create("loop-symlink").is_err());
659 assert!(mgr.list().is_err());
660 assert!(
661 std::fs::read_dir(outside.path())
662 .expect("read outside directory")
663 .next()
664 .is_none()
665 );
666 }
667}