1use 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#[derive(Debug, Clone)]
15pub struct CreateOptions {
16 pub task_id: String,
18
19 pub base_branch: Option<String>,
21
22 pub force: bool,
24
25 pub custom_path: Option<PathBuf>,
27}
28
29#[derive(Debug, Clone)]
31pub struct RemoveOptions {
32 pub target: String,
34
35 pub force: bool,
37
38 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#[derive(Debug, Clone)]
65pub enum WorktreeOperation {
66 Create(CreateOptions),
67 Remove(RemoveOptions),
68 List,
69 Status(String),
70}
71
72#[derive(Clone)]
74pub struct WorktreeOperations {
75 repo_root: PathBuf,
76 config: WorktreeConfig,
77 repo_name: Option<String>,
78}
79
80impl WorktreeOperations {
81 pub fn new(repo_root: PathBuf, config: WorktreeConfig) -> Self {
83 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 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 pub async fn create_worktree(&self, options: CreateOptions) -> Result<WorktreeInfo> {
111 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(&branch_name)?;
117
118 let worktree_path = match options.custom_path {
120 Some(custom) => custom,
121 None => self.calculate_worktree_path(&sanitized_task_id)?,
122 };
123
124 self.ensure_base_directory_exists().await?;
126
127 if self.config.auto_gitignore {
129 self.update_gitignore().await?;
130 }
131
132 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 let result = if branch_exists && options.force {
143 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 self.execute_git_command(&["branch", "-D", &branch_name])
157 .await
158 .ok(); 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 Ok(WorktreeInfo {
182 path: worktree_path,
183 branch: branch_name,
184 head: result.head,
185 task_id: Some(options.task_id), status: Default::default(), age: std::time::Duration::from_secs(0),
188 is_detached: false,
189 })
190 }
191
192 pub async fn remove_worktree(&self, options: RemoveOptions) -> Result<()> {
194 let worktree_info = self.resolve_worktree_target(&options.target).await?;
196 let worktree_path = worktree_info.path;
197
198 if !worktree_path.exists() {
200 bail!("Worktree path does not exist: {}", worktree_path.display());
201 }
202
203 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 let branch_name_for_deletion = if options.delete_branch {
213 Some(worktree_info.branch.clone())
214 } else {
215 None
216 };
217
218 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 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 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 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 pub fn get_config(&self) -> &WorktreeConfig {
257 &self.config
258 }
259
260 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 pub async fn resolve_worktree_target(&self, target: &str) -> Result<WorktreeInfo> {
270 if let Some(worktree) = self.find_worktree_by_task_id(target).await? {
272 return Ok(worktree);
273 }
274
275 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 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 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 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 let base_path = if self.config.base_dir.is_absolute() {
328 self.config.base_dir.clone()
330 } else {
331 self.repo_root.join(&self.config.base_dir)
332 };
333
334 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 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 let base_path = if self.config.base_dir.is_absolute() {
365 self.config.base_dir.clone()
366 } else {
367 if let Some(home) = dirs::home_dir() {
370 home.join(".toolprint")
371 .join("vibe-workspace")
372 .join("worktrees")
373 } else {
374 std::env::temp_dir().join("vibe-worktrees")
376 }
377 };
378
379 let repo_name = self.repo_name.as_ref().ok_or_else(|| {
381 anyhow::anyhow!("Repository name required for global worktree mode")
382 })?;
383
384 let timestamp = std::time::SystemTime::now()
386 .duration_since(std::time::UNIX_EPOCH)?
387 .as_secs();
388
389 let safe_task_id = task_id.replace('/', "-");
391 let worktree_name = format!("{}__{:x}", safe_task_id, timestamp);
392
393 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 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 if self.config.mode == WorktreeMode::Global {
441 return Ok(()); }
443
444 let base_path = if self.config.base_dir.is_absolute() {
445 return Ok(()); } 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 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(()); }
462 }
463
464 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 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 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 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
646pub fn validate_branch_name(branch_name: &str) -> Result<()> {
648 if branch_name.is_empty() {
649 bail!("Branch name cannot be empty");
650 }
651
652 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 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 if branch_name.len() > 255 {
679 bail!("Branch name too long (max 255 characters)");
680 }
681
682 Ok(())
683}
684
685pub fn sanitize_branch_name(name: &str) -> Result<String> {
687 if name.is_empty() {
688 bail!("Task ID cannot be empty");
689 }
690
691 let re = Regex::new(r"[^a-zA-Z0-9\-_/]")?;
693 let sanitized = re.replace_all(name, "-").to_string();
694
695 let re = Regex::new(r"-+")?;
697 let sanitized = re.replace_all(&sanitized, "-").to_string();
698
699 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 Command::new("git")
724 .args(&["init"])
725 .current_dir(&repo_path)
726 .status()
727 .await?;
728
729 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 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 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 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 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 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 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 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 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 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 assert!(sanitize_branch_name("").is_err());
880 assert!(sanitize_branch_name("!!!").is_err());
881 assert!(sanitize_branch_name("---").is_err());
882 }
883}