Skip to main content

vtcode_core/git/
worktree.rs

1//! Git worktree management for loop isolation.
2//!
3//! A `WorktreeManager` creates, lists, and removes git worktrees under
4//! `{workspace}/.vtcode/worktrees/`. Each parallel loop run gets its own
5//! worktree so concurrent agents cannot collide on the working tree.
6
7use 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/// Build a Git command whose cwd is bound to an already validated directory
19/// handle on Unix. The non-Unix implementation retains the validated path as
20/// the child cwd because those platforms do not expose a portable descriptor
21/// based `fchdir` primitive; callers still validate every path before this
22/// helper is reached.
23#[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// ─── WorktreeInfo ────────────────────────────────────────────────────────────
47
48/// Information about a discovered worktree.
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct WorktreeInfo {
51    /// The worktree name (directory name under `.vtcode/worktrees/`).
52    pub name: String,
53    /// Absolute path to the worktree working directory.
54    pub path: PathBuf,
55    /// The HEAD commit hash of the worktree.
56    pub head: Option<String>,
57    /// Whether the worktree has uncommitted changes.
58    pub is_dirty: bool,
59}
60
61// ─── WorktreeManager ─────────────────────────────────────────────────────────
62
63/// Manages git worktrees for loop isolation. Each worktree is an independent
64/// checkout of the repository that can be worked on in parallel without
65/// interfering with other worktrees or the main working tree.
66pub struct WorktreeManager {
67    workspace_root: PathBuf,
68}
69
70impl WorktreeManager {
71    /// Create a new `WorktreeManager` for the given workspace.
72    pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
73        Self { workspace_root: workspace_root.into() }
74    }
75
76    /// The directory where worktrees are stored.
77    pub fn worktrees_dir(&self) -> PathBuf {
78        self.workspace_root.join(".vtcode").join(WORKTREES_DIR_NAME)
79    }
80
81    /// Create a new worktree with the given name. Returns the path to the
82    /// new worktree's working directory.
83    ///
84    /// The worktree is created under `.vtcode/worktrees/{name}/` on a new
85    /// branch named `loop/{name}`.
86    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    /// Create a worktree and apply the current tracked and untracked changes
133    /// from the source checkout. This keeps isolated executions aligned with
134    /// the reviewable working tree even when the source changes are not
135    /// committed yet.
136    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            // Re-open the no-follow directory chain directly before handing
178            // the path to Git. The subsequent untracked-file copy remains
179            // handle-bound on Unix, so a replaced destination cannot redirect
180            // snapshot contents outside the workspace.
181            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    /// List all worktrees managed by this instance (under `.vtcode/worktrees/`).
268    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        /// Build a WorktreeInfo if the path is under the managed directory.
295        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 we have a pending entry, check if it's managed
306                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                // Detached HEAD state, head already set
317            }
318        }
319
320        // Handle last entry
321        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        // Check dirty status for each worktree
328        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    /// Remove a worktree by name. Runs `git worktree remove` with `--force`
355    /// to handle worktrees with uncommitted changes.
356    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        // Clean up the orphan branch created by `create()`.
401        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                // Branch may not exist if the worktree was created externally;
407                // log but do not fail the removal.
408                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    /// Remove all worktrees managed by this instance.
456    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
467// ─── Helpers ─────────────────────────────────────────────────────────────────
468
469fn 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// ─── Tests ───────────────────────────────────────────────────────────────────
484
485#[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    // Integration tests below exercise the real `git worktree` CLI against a
524    // throwaway git repo, fulfilling the plan's B1 "create/list/remove against a
525    // temp git repo" verification requirement (previously only sanitization was
526    // covered).
527
528    use std::process::Command as ProcCommand;
529
530    /// Build a `WorktreeManager` from a canonicalized repo root.
531    ///
532    /// `git worktree list --porcelain` returns canonical (symlink-resolved)
533    /// paths, so the manager must be constructed from a canonical root or its
534    /// `starts_with(managed_dir)` filter would miss worktrees when the caller
535    /// passes a symlinked path (e.g. macOS `/tmp` -> `/private/tmp`).
536    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        // The orphan `loop/{name}` branch created by `create()` must be GC'd too.
617        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}