Skip to main content

vibe_workspace/worktree/
operations.rs

1//! Core Git worktree operations
2
3use anyhow::{bail, Context, Result};
4use regex::Regex;
5use std::fs;
6use std::path::{Path, PathBuf};
7use tokio::process::Command;
8use tracing::{debug, warn};
9
10use crate::worktree::config::{WorktreeConfig, WorktreeMode};
11use crate::worktree::status::WorktreeInfo;
12
13/// Options for creating a new worktree
14#[derive(Debug, Clone)]
15pub struct CreateOptions {
16    /// Task identifier to generate branch name
17    pub task_id: String,
18
19    /// Base branch to create worktree from (default: current branch)
20    pub base_branch: Option<String>,
21
22    /// Force creation even if branch exists
23    pub force: bool,
24
25    /// Custom worktree path (overrides default path calculation)
26    pub custom_path: Option<PathBuf>,
27}
28
29/// Options for removing a worktree
30#[derive(Debug, Clone)]
31pub struct RemoveOptions {
32    /// Branch name or worktree path to remove
33    pub target: String,
34
35    /// Force removal even with uncommitted changes
36    pub force: bool,
37
38    /// Also delete the branch after removing worktree
39    pub delete_branch: bool,
40}
41
42impl Default for CreateOptions {
43    fn default() -> Self {
44        Self {
45            task_id: String::new(),
46            base_branch: None,
47            force: false,
48            custom_path: None,
49        }
50    }
51}
52
53impl Default for RemoveOptions {
54    fn default() -> Self {
55        Self {
56            target: String::new(),
57            force: false,
58            delete_branch: false,
59        }
60    }
61}
62
63/// Git worktree operation types
64#[derive(Debug, Clone)]
65pub enum WorktreeOperation {
66    Create(CreateOptions),
67    Remove(RemoveOptions),
68    List,
69    Status(String),
70}
71
72/// Core worktree operations implementation
73#[derive(Clone)]
74pub struct WorktreeOperations {
75    repo_root: PathBuf,
76    config: WorktreeConfig,
77    repo_name: Option<String>,
78}
79
80impl WorktreeOperations {
81    /// Create new operations instance
82    pub fn new(repo_root: PathBuf, config: WorktreeConfig) -> Self {
83        // Try to extract repository name from the path
84        let repo_name = repo_root
85            .file_name()
86            .and_then(|n| n.to_str())
87            .map(|s| s.to_string());
88
89        Self {
90            repo_root,
91            config,
92            repo_name,
93        }
94    }
95
96    /// Create new operations instance with explicit repository name
97    pub fn new_with_repo_name(
98        repo_root: PathBuf,
99        config: WorktreeConfig,
100        repo_name: String,
101    ) -> Self {
102        Self {
103            repo_root,
104            config,
105            repo_name: Some(repo_name),
106        }
107    }
108
109    /// Create a new git worktree
110    pub async fn create_worktree(&self, options: CreateOptions) -> Result<WorktreeInfo> {
111        // Validate and sanitize the task ID
112        let sanitized_task_id = sanitize_branch_name(&options.task_id)?;
113        let branch_name = format!("{}{}", self.config.prefix, sanitized_task_id);
114
115        // Validate branch name
116        validate_branch_name(&branch_name)?;
117
118        // Calculate worktree path
119        let worktree_path = match options.custom_path {
120            Some(custom) => custom,
121            None => self.calculate_worktree_path(&sanitized_task_id)?,
122        };
123
124        // Ensure base directory exists
125        self.ensure_base_directory_exists().await?;
126
127        // Update .gitignore if needed
128        if self.config.auto_gitignore {
129            self.update_gitignore().await?;
130        }
131
132        // Check if branch already exists
133        let branch_exists = self.branch_exists(&branch_name).await?;
134        if branch_exists && !options.force {
135            bail!(
136                "Branch '{}' already exists. Use --force to recreate.",
137                branch_name
138            );
139        }
140
141        // Create the worktree
142        let result = if branch_exists && options.force {
143            // Remove existing worktree first if it exists
144            if let Ok(existing_path) = self.find_worktree_path(&branch_name).await {
145                warn!("Removing existing worktree at: {}", existing_path.display());
146                self.execute_git_command(&[
147                    "worktree",
148                    "remove",
149                    "--force",
150                    &existing_path.to_string_lossy(),
151                ])
152                .await?;
153            }
154
155            // Remove and recreate branch
156            self.execute_git_command(&["branch", "-D", &branch_name])
157                .await
158                .ok(); // Ignore errors
159            self.create_branch_and_worktree(
160                &branch_name,
161                &worktree_path,
162                options.base_branch.as_deref(),
163            )
164            .await?
165        } else {
166            self.create_branch_and_worktree(
167                &branch_name,
168                &worktree_path,
169                options.base_branch.as_deref(),
170            )
171            .await?
172        };
173
174        debug!(
175            "Created worktree: {} -> {}",
176            branch_name,
177            worktree_path.display()
178        );
179
180        // Return worktree info
181        Ok(WorktreeInfo {
182            path: worktree_path,
183            branch: branch_name,
184            head: result.head,
185            task_id: Some(options.task_id), // Store the original task ID
186            status: Default::default(),     // Will be filled by status tracking
187            age: std::time::Duration::from_secs(0),
188            is_detached: false,
189        })
190    }
191
192    /// Remove a git worktree
193    pub async fn remove_worktree(&self, options: RemoveOptions) -> Result<()> {
194        // Use enhanced resolution that tries task_id first, then path, then branch
195        let worktree_info = self.resolve_worktree_target(&options.target).await?;
196        let worktree_path = worktree_info.path;
197
198        // Validate worktree exists
199        if !worktree_path.exists() {
200            bail!("Worktree path does not exist: {}", worktree_path.display());
201        }
202
203        // Safety check: ensure it's actually a worktree
204        if !self.is_valid_worktree(&worktree_path).await? {
205            bail!(
206                "Path is not a valid git worktree: {}",
207                worktree_path.display()
208            );
209        }
210
211        // Extract branch name BEFORE removing the worktree if needed for deletion
212        let branch_name_for_deletion = if options.delete_branch {
213            Some(worktree_info.branch.clone())
214        } else {
215            None
216        };
217
218        // Remove the worktree
219        let mut args = vec!["worktree", "remove"];
220        if options.force {
221            args.push("--force");
222        }
223        let path_str = worktree_path.to_string_lossy();
224        args.push(&path_str);
225
226        self.execute_git_command(&args).await?;
227
228        // Delete branch if requested (after worktree removal)
229        if let Some(branch_name) = branch_name_for_deletion {
230            self.execute_git_command(&["branch", "-D", &branch_name])
231                .await?;
232            debug!("Deleted branch: {}", branch_name);
233        }
234
235        debug!("Removed worktree: {}", worktree_path.display());
236        Ok(())
237    }
238
239    /// List all git worktrees
240    pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
241        let output = self
242            .execute_git_command(&["worktree", "list", "--porcelain"])
243            .await?;
244        self.parse_worktree_list(&output).await
245    }
246
247    /// Find git repository root
248    pub async fn find_git_root(&self) -> Result<PathBuf> {
249        let output = self
250            .execute_git_command(&["rev-parse", "--show-toplevel"])
251            .await?;
252        Ok(PathBuf::from(output.trim()))
253    }
254
255    /// Get a reference to the config
256    pub fn get_config(&self) -> &WorktreeConfig {
257        &self.config
258    }
259
260    /// Find worktree by task ID
261    pub async fn find_worktree_by_task_id(&self, task_id: &str) -> Result<Option<WorktreeInfo>> {
262        let worktrees = self.list_worktrees().await?;
263        Ok(worktrees
264            .into_iter()
265            .find(|w| w.task_id.as_ref().map_or(false, |id| id == task_id)))
266    }
267
268    /// Resolve target (task ID, branch name, or path) to worktree info
269    pub async fn resolve_worktree_target(&self, target: &str) -> Result<WorktreeInfo> {
270        // First try as task ID
271        if let Some(worktree) = self.find_worktree_by_task_id(target).await? {
272            return Ok(worktree);
273        }
274
275        // Try as direct path
276        if let Ok(path) = PathBuf::from(target).canonicalize() {
277            let worktrees = self.list_worktrees().await?;
278            if let Some(worktree) = worktrees.into_iter().find(|w| w.path == path) {
279                return Ok(worktree);
280            }
281        }
282
283        // Try as branch name
284        let worktrees = self.list_worktrees().await?;
285        if let Some(worktree) = worktrees.into_iter().find(|w| w.branch == target) {
286            return Ok(worktree);
287        }
288
289        bail!("Worktree not found: {}", target)
290    }
291
292    // Private implementation methods
293
294    async fn create_branch_and_worktree(
295        &self,
296        branch_name: &str,
297        worktree_path: &Path,
298        base_branch: Option<&str>,
299    ) -> Result<CreateResult> {
300        let base = base_branch.unwrap_or("HEAD");
301
302        let _output = self
303            .execute_git_command(&[
304                "worktree",
305                "add",
306                "-b",
307                branch_name,
308                &worktree_path.to_string_lossy(),
309                base,
310            ])
311            .await?;
312
313        // Get the HEAD commit
314        let head = self
315            .execute_git_command(&["rev-parse", "HEAD"])
316            .await?
317            .trim()
318            .to_string();
319
320        Ok(CreateResult { head })
321    }
322
323    fn calculate_worktree_path(&self, task_id: &str) -> Result<PathBuf> {
324        match self.config.mode {
325            WorktreeMode::Local => {
326                // Local mode: worktrees are stored relative to repo root
327                let base_path = if self.config.base_dir.is_absolute() {
328                    // Even in local mode, allow absolute paths for flexibility
329                    self.config.base_dir.clone()
330                } else {
331                    self.repo_root.join(&self.config.base_dir)
332                };
333
334                // Handle task IDs with slashes (e.g., "feat/new-ui" -> "feat/new-ui")
335                let path_segments: Vec<&str> = task_id.split('/').collect();
336                let mut worktree_path = base_path;
337
338                for segment in path_segments {
339                    worktree_path = worktree_path.join(segment);
340                }
341
342                // Add timestamp suffix to ensure uniqueness
343                let timestamp = std::time::SystemTime::now()
344                    .duration_since(std::time::UNIX_EPOCH)?
345                    .as_secs();
346
347                let final_name = format!(
348                    "{}__{:x}",
349                    worktree_path
350                        .file_name()
351                        .and_then(|n| n.to_str())
352                        .unwrap_or("worktree"),
353                    timestamp
354                );
355
356                Ok(worktree_path
357                    .parent()
358                    .unwrap_or(&worktree_path)
359                    .join(final_name))
360            }
361            WorktreeMode::Global => {
362                // Global mode: worktrees are stored in a central location
363                // Structure: {base_dir}/{repo_name}/{task_id}__{timestamp}
364                let base_path = if self.config.base_dir.is_absolute() {
365                    self.config.base_dir.clone()
366                } else {
367                    // If base_dir is relative in global mode, make it relative to home directory
368                    // or a central workspace location
369                    if let Some(home) = dirs::home_dir() {
370                        home.join(".toolprint")
371                            .join("vibe-workspace")
372                            .join("worktrees")
373                    } else {
374                        // Fallback to absolute path in temp directory
375                        std::env::temp_dir().join("vibe-worktrees")
376                    }
377                };
378
379                // Get repository name for directory structure
380                let repo_name = self.repo_name.as_ref().ok_or_else(|| {
381                    anyhow::anyhow!("Repository name required for global worktree mode")
382                })?;
383
384                // Add timestamp suffix to ensure uniqueness
385                let timestamp = std::time::SystemTime::now()
386                    .duration_since(std::time::UNIX_EPOCH)?
387                    .as_secs();
388
389                // Handle task IDs with slashes by replacing them with dashes
390                let safe_task_id = task_id.replace('/', "-");
391                let worktree_name = format!("{}__{:x}", safe_task_id, timestamp);
392
393                // Create path: base_dir/repo_name/worktree_name
394                Ok(base_path.join(repo_name).join(worktree_name))
395            }
396        }
397    }
398
399    async fn ensure_base_directory_exists(&self) -> Result<()> {
400        let base_path = match self.config.mode {
401            WorktreeMode::Local => {
402                if self.config.base_dir.is_absolute() {
403                    self.config.base_dir.clone()
404                } else {
405                    self.repo_root.join(&self.config.base_dir)
406                }
407            }
408            WorktreeMode::Global => {
409                let base = if self.config.base_dir.is_absolute() {
410                    self.config.base_dir.clone()
411                } else {
412                    if let Some(home) = dirs::home_dir() {
413                        home.join(".toolprint")
414                            .join("vibe-workspace")
415                            .join("worktrees")
416                    } else {
417                        std::env::temp_dir().join("vibe-worktrees")
418                    }
419                };
420                // In global mode, also create the repository subdirectory
421                if let Some(repo_name) = &self.repo_name {
422                    base.join(repo_name)
423                } else {
424                    base
425                }
426            }
427        };
428
429        if !base_path.exists() {
430            fs::create_dir_all(&base_path).with_context(|| {
431                format!("Failed to create base directory: {}", base_path.display())
432            })?;
433        }
434
435        Ok(())
436    }
437
438    async fn update_gitignore(&self) -> Result<()> {
439        // Only update .gitignore in local mode when worktrees are within the repository
440        if self.config.mode == WorktreeMode::Global {
441            return Ok(()); // Global worktrees don't need .gitignore
442        }
443
444        let base_path = if self.config.base_dir.is_absolute() {
445            return Ok(()); // External worktrees don't need .gitignore
446        } else {
447            self.config.base_dir.clone()
448        };
449
450        let gitignore_path = self.repo_root.join(".gitignore");
451        let ignore_pattern = format!("{}/", base_path.display());
452
453        // Check if pattern already exists
454        if gitignore_path.exists() {
455            let content = fs::read_to_string(&gitignore_path)?;
456            if content
457                .lines()
458                .any(|line| line.trim() == ignore_pattern.trim())
459            {
460                return Ok(()); // Already present
461            }
462        }
463
464        // Append the ignore pattern
465        let mut content = if gitignore_path.exists() {
466            fs::read_to_string(&gitignore_path)?
467        } else {
468            String::new()
469        };
470
471        if !content.is_empty() && !content.ends_with('\n') {
472            content.push('\n');
473        }
474
475        content.push_str(&format!(
476            "# Vibe worktree directories\n{}\n",
477            ignore_pattern
478        ));
479
480        fs::write(&gitignore_path, content).with_context(|| {
481            format!(
482                "Failed to update .gitignore at: {}",
483                gitignore_path.display()
484            )
485        })?;
486
487        debug!("Updated .gitignore with pattern: {}", ignore_pattern);
488        Ok(())
489    }
490
491    async fn branch_exists(&self, branch_name: &str) -> Result<bool> {
492        let result = self
493            .execute_git_command(&[
494                "show-ref",
495                "--verify",
496                "--quiet",
497                &format!("refs/heads/{}", branch_name),
498            ])
499            .await;
500        Ok(result.is_ok())
501    }
502
503    async fn find_worktree_path(&self, branch_name: &str) -> Result<PathBuf> {
504        let worktrees = self.list_worktrees().await?;
505        for worktree in worktrees {
506            if worktree.branch == branch_name {
507                return Ok(worktree.path);
508            }
509        }
510        bail!("No worktree found for branch: {}", branch_name);
511    }
512
513    async fn is_valid_worktree(&self, path: &Path) -> Result<bool> {
514        if !path.exists() {
515            return Ok(false);
516        }
517
518        // Check if git recognizes this as a worktree
519        let result = Command::new("git")
520            .args(&["rev-parse", "--show-toplevel"])
521            .current_dir(path)
522            .output()
523            .await?;
524
525        Ok(result.status.success())
526    }
527
528    async fn get_worktree_branch(&self, worktree_path: &Path) -> Result<String> {
529        let output = Command::new("git")
530            .args(&["rev-parse", "--abbrev-ref", "HEAD"])
531            .current_dir(worktree_path)
532            .output()
533            .await?;
534
535        if !output.status.success() {
536            bail!(
537                "Failed to get branch name for worktree: {}",
538                worktree_path.display()
539            );
540        }
541
542        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
543    }
544
545    async fn parse_worktree_list(&self, output: &str) -> Result<Vec<WorktreeInfo>> {
546        let mut worktrees = Vec::new();
547        let lines: Vec<&str> = output.lines().collect();
548        let mut i = 0;
549
550        while i < lines.len() {
551            let line = lines[i];
552            if line.starts_with("worktree ") {
553                let path = PathBuf::from(line.strip_prefix("worktree ").unwrap());
554                let mut branch = String::new();
555                let mut head = String::new();
556                let mut is_detached = false;
557
558                i += 1;
559                while i < lines.len() && !lines[i].starts_with("worktree ") {
560                    let info_line = lines[i];
561                    if info_line.starts_with("HEAD ") {
562                        head = info_line.strip_prefix("HEAD ").unwrap().to_string();
563                    } else if info_line.starts_with("branch ") {
564                        let branch_ref = info_line.strip_prefix("branch ").unwrap();
565                        branch = branch_ref
566                            .strip_prefix("refs/heads/")
567                            .unwrap_or(branch_ref)
568                            .to_string();
569                    } else if info_line == "detached" {
570                        is_detached = true;
571                        branch = "(detached)".to_string();
572                    } else if info_line == "bare" {
573                        branch = "(bare)".to_string();
574                    }
575                    i += 1;
576                }
577
578                // Calculate age
579                let age = if let Ok(metadata) = fs::metadata(&path) {
580                    if let Ok(created) = metadata.created() {
581                        std::time::SystemTime::now()
582                            .duration_since(created)
583                            .unwrap_or_default()
584                    } else {
585                        std::time::Duration::from_secs(0)
586                    }
587                } else {
588                    std::time::Duration::from_secs(0)
589                };
590
591                // Try to extract task_id from branch name by removing prefix
592                let task_id = if branch.starts_with(&self.config.prefix) {
593                    Some(
594                        branch
595                            .strip_prefix(&self.config.prefix)
596                            .unwrap_or(&branch)
597                            .to_string(),
598                    )
599                } else {
600                    None
601                };
602
603                worktrees.push(WorktreeInfo {
604                    path,
605                    branch,
606                    head,
607                    task_id,
608                    status: Default::default(),
609                    age,
610                    is_detached,
611                });
612            } else {
613                i += 1;
614            }
615        }
616
617        Ok(worktrees)
618    }
619
620    async fn execute_git_command(&self, args: &[&str]) -> Result<String> {
621        let output = Command::new("git")
622            .args(args)
623            .current_dir(&self.repo_root)
624            .output()
625            .await
626            .with_context(|| format!("Failed to execute git command: git {}", args.join(" ")))?;
627
628        if !output.status.success() {
629            let stderr = String::from_utf8_lossy(&output.stderr);
630            bail!(
631                "Git command failed: git {}\nError: {}",
632                args.join(" "),
633                stderr
634            );
635        }
636
637        Ok(String::from_utf8_lossy(&output.stdout).to_string())
638    }
639}
640
641#[derive(Debug)]
642struct CreateResult {
643    head: String,
644}
645
646/// Validate a Git branch name for security and compatibility
647pub fn validate_branch_name(branch_name: &str) -> Result<()> {
648    if branch_name.is_empty() {
649        bail!("Branch name cannot be empty");
650    }
651
652    // Security: Check for dangerous characters that could lead to command injection
653    let dangerous_chars = [
654        '$', '`', '(', ')', '{', '}', '|', '&', ';', '<', '>', '\n', '\r', '\0', '"', '\'', '\\',
655    ];
656    if branch_name.chars().any(|c| dangerous_chars.contains(&c)) {
657        bail!("Branch name contains invalid characters");
658    }
659
660    // Git branch name validation
661    if branch_name.starts_with('.') || branch_name.ends_with('.') {
662        bail!("Branch name cannot start or end with a dot");
663    }
664
665    if branch_name.starts_with('/') || branch_name.ends_with('/') {
666        bail!("Branch name cannot start or end with a slash");
667    }
668
669    if branch_name.contains("..") {
670        bail!("Branch name cannot contain consecutive dots");
671    }
672
673    if branch_name.contains("@{") {
674        bail!("Branch name cannot contain '@{{' sequence");
675    }
676
677    // Length validation
678    if branch_name.len() > 255 {
679        bail!("Branch name too long (max 255 characters)");
680    }
681
682    Ok(())
683}
684
685/// Sanitize a task ID to create a valid Git branch name
686pub fn sanitize_branch_name(name: &str) -> Result<String> {
687    if name.is_empty() {
688        bail!("Task ID cannot be empty");
689    }
690
691    // Replace invalid characters with hyphens
692    let re = Regex::new(r"[^a-zA-Z0-9\-_/]")?;
693    let sanitized = re.replace_all(name, "-").to_string();
694
695    // Remove multiple consecutive hyphens
696    let re = Regex::new(r"-+")?;
697    let sanitized = re.replace_all(&sanitized, "-").to_string();
698
699    // Trim leading/trailing hyphens and slashes
700    let sanitized = sanitized.trim_matches('-').trim_matches('/');
701
702    if sanitized.is_empty() {
703        bail!(
704            "Task ID '{}' cannot be sanitized to a valid branch name",
705            name
706        );
707    }
708
709    Ok(sanitized.to_string())
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use tempfile::TempDir;
716    use tokio;
717
718    async fn setup_test_repo() -> Result<(TempDir, PathBuf)> {
719        let temp_dir = TempDir::new()?;
720        let repo_path = temp_dir.path().to_path_buf();
721
722        // Initialize git repo
723        Command::new("git")
724            .args(&["init"])
725            .current_dir(&repo_path)
726            .status()
727            .await?;
728
729        // Set up git config
730        Command::new("git")
731            .args(&["config", "user.email", "test@example.com"])
732            .current_dir(&repo_path)
733            .status()
734            .await?;
735
736        Command::new("git")
737            .args(&["config", "user.name", "Test User"])
738            .current_dir(&repo_path)
739            .status()
740            .await?;
741
742        // Create initial commit
743        Command::new("git")
744            .args(&["commit", "--allow-empty", "-m", "Initial commit"])
745            .current_dir(&repo_path)
746            .status()
747            .await?;
748
749        Ok((temp_dir, repo_path))
750    }
751
752    #[tokio::test]
753    async fn test_create_worktree() -> Result<()> {
754        let (_temp_dir, repo_path) = setup_test_repo().await?;
755        let config = WorktreeConfig::default();
756        let ops = WorktreeOperations::new(repo_path, config);
757
758        let options = CreateOptions {
759            task_id: "test-feature".to_string(),
760            base_branch: None,
761            force: false,
762            custom_path: None,
763        };
764
765        let worktree_info = ops.create_worktree(options).await?;
766
767        assert!(worktree_info.path.exists());
768        assert!(worktree_info.branch.starts_with("vibe-ws/"));
769        assert!(worktree_info.branch.contains("test-feature"));
770
771        Ok(())
772    }
773
774    #[tokio::test]
775    async fn test_list_worktrees() -> Result<()> {
776        let (_temp_dir, repo_path) = setup_test_repo().await?;
777        let config = WorktreeConfig::default();
778        let ops = WorktreeOperations::new(repo_path, config);
779
780        // Should have at least the main worktree
781        let worktrees = ops.list_worktrees().await?;
782        assert!(!worktrees.is_empty());
783
784        Ok(())
785    }
786
787    #[tokio::test]
788    async fn test_remove_worktree() -> Result<()> {
789        let (_temp_dir, repo_path) = setup_test_repo().await?;
790        let config = WorktreeConfig::default();
791        let ops = WorktreeOperations::new(repo_path, config);
792
793        // Create a worktree first
794        let create_options = CreateOptions {
795            task_id: "test-remove".to_string(),
796            base_branch: None,
797            force: false,
798            custom_path: None,
799        };
800
801        let worktree_info = ops.create_worktree(create_options).await?;
802        assert!(worktree_info.path.exists());
803
804        // Remove it
805        let remove_options = RemoveOptions {
806            target: worktree_info.branch.clone(),
807            force: false,
808            delete_branch: true,
809        };
810
811        ops.remove_worktree(remove_options).await?;
812        assert!(!worktree_info.path.exists());
813
814        Ok(())
815    }
816
817    #[tokio::test]
818    async fn test_path_with_slashes() -> Result<()> {
819        let (_temp_dir, repo_path) = setup_test_repo().await?;
820        let config = WorktreeConfig::default();
821        let ops = WorktreeOperations::new(repo_path, config);
822
823        let options = CreateOptions {
824            task_id: "feat/new-ui".to_string(),
825            base_branch: None,
826            force: false,
827            custom_path: None,
828        };
829
830        let worktree_info = ops.create_worktree(options).await?;
831
832        // Should create subdirectory structure
833        assert!(worktree_info.path.exists());
834        assert!(worktree_info.path.to_string_lossy().contains("feat"));
835
836        Ok(())
837    }
838
839    #[test]
840    fn test_validate_branch_name() {
841        // Valid names
842        assert!(validate_branch_name("feature/new-ui").is_ok());
843        assert!(validate_branch_name("vibe-ws/task-123").is_ok());
844        assert!(validate_branch_name("main").is_ok());
845
846        // Invalid names
847        assert!(validate_branch_name("").is_err());
848        assert!(validate_branch_name(".hidden").is_err());
849        assert!(validate_branch_name("branch.").is_err());
850        assert!(validate_branch_name("/branch").is_err());
851        assert!(validate_branch_name("branch/").is_err());
852        assert!(validate_branch_name("branch..name").is_err());
853        assert!(validate_branch_name("branch@{upstream}").is_err());
854        assert!(validate_branch_name("branch$injection").is_err());
855        assert!(validate_branch_name("branch`command`").is_err());
856    }
857
858    #[test]
859    fn test_sanitize_branch_name() {
860        // Basic sanitization
861        assert_eq!(sanitize_branch_name("Task 123").unwrap(), "Task-123");
862        assert_eq!(sanitize_branch_name("feat/new-ui").unwrap(), "feat/new-ui");
863        assert_eq!(
864            sanitize_branch_name("Fix: issue #456").unwrap(),
865            "Fix-issue-456"
866        );
867
868        // Multiple consecutive characters
869        assert_eq!(
870            sanitize_branch_name("task   with   spaces").unwrap(),
871            "task-with-spaces"
872        );
873        assert_eq!(
874            sanitize_branch_name("task---dashes").unwrap(),
875            "task-dashes"
876        );
877
878        // Edge cases
879        assert!(sanitize_branch_name("").is_err());
880        assert!(sanitize_branch_name("!!!").is_err());
881        assert!(sanitize_branch_name("---").is_err());
882    }
883}