1use std::fs::{self, File, OpenOptions};
32use std::io::{self, BufRead, BufReader, Write};
33use std::path::{Path, PathBuf};
34use std::process::Command;
35
36#[derive(Debug, Clone)]
38pub struct WorktreeConfig {
39 pub worktree_dir: PathBuf,
41}
42
43impl Default for WorktreeConfig {
44 fn default() -> Self {
45 Self {
46 worktree_dir: PathBuf::from(".worktrees"),
47 }
48 }
49}
50
51impl WorktreeConfig {
52 pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
54 Self {
55 worktree_dir: dir.into(),
56 }
57 }
58
59 pub fn worktree_path(&self, repo_root: &Path) -> PathBuf {
61 if self.worktree_dir.is_absolute() {
62 self.worktree_dir.clone()
63 } else {
64 repo_root.join(&self.worktree_dir)
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct Worktree {
72 pub path: PathBuf,
74
75 pub branch: String,
77
78 pub is_main: bool,
80
81 pub head: Option<String>,
83}
84
85#[derive(Debug, Default, Clone)]
87pub struct SyncStats {
88 pub untracked_copied: usize,
90 pub modified_copied: usize,
92 pub skipped: usize,
94 pub errors: usize,
96}
97
98#[derive(Debug, thiserror::Error)]
100pub enum WorktreeError {
101 #[error("IO error: {0}")]
103 Io(#[from] io::Error),
104
105 #[error("Git command failed: {0}")]
107 Git(String),
108
109 #[error("Worktree already exists: {0}")]
111 AlreadyExists(String),
112
113 #[error("Worktree not found: {0}")]
115 NotFound(String),
116
117 #[error("Not a git repository: {0}")]
119 NotARepo(String),
120
121 #[error("Branch already exists: {0}")]
123 BranchExists(String),
124}
125
126pub fn create_worktree(
141 repo_root: impl AsRef<Path>,
142 loop_id: &str,
143 config: &WorktreeConfig,
144) -> Result<Worktree, WorktreeError> {
145 let repo_root = repo_root.as_ref();
146
147 if !repo_root.join(".git").exists() && !repo_root.join(".git").is_file() {
149 return Err(WorktreeError::NotARepo(
150 repo_root.to_string_lossy().to_string(),
151 ));
152 }
153
154 let worktree_base = config.worktree_path(repo_root);
155 let worktree_path = worktree_base.join(loop_id);
156 let branch_name = format!("ralph/{loop_id}");
157
158 if worktree_path.exists() {
160 return Err(WorktreeError::AlreadyExists(
161 worktree_path.to_string_lossy().to_string(),
162 ));
163 }
164
165 fs::create_dir_all(&worktree_base)?;
167
168 let output = Command::new("git")
171 .args(["worktree", "add", "-b", &branch_name])
172 .arg(&worktree_path)
173 .current_dir(repo_root)
174 .output()?;
175
176 if !output.status.success() {
177 let stderr = String::from_utf8_lossy(&output.stderr);
178
179 if stderr.contains("already exists") {
181 if stderr.contains("branch") {
182 return Err(WorktreeError::BranchExists(branch_name));
183 }
184 return Err(WorktreeError::AlreadyExists(
185 worktree_path.to_string_lossy().to_string(),
186 ));
187 }
188
189 return Err(WorktreeError::Git(stderr.to_string()));
190 }
191
192 let sync_stats = sync_working_directory_to_worktree(repo_root, &worktree_path, config)?;
194
195 if sync_stats.errors > 0 {
196 tracing::warn!(
197 "Some files failed to sync to worktree: {} errors",
198 sync_stats.errors
199 );
200 }
201
202 let head = get_head_commit(&worktree_path).ok();
204
205 tracing::debug!(
206 "Created worktree at {} on branch {} (synced {} untracked, {} modified files)",
207 worktree_path.display(),
208 branch_name,
209 sync_stats.untracked_copied,
210 sync_stats.modified_copied
211 );
212
213 Ok(Worktree {
214 path: worktree_path,
215 branch: branch_name,
216 is_main: false,
217 head,
218 })
219}
220
221pub fn remove_worktree(
232 repo_root: impl AsRef<Path>,
233 worktree_path: impl AsRef<Path>,
234) -> Result<(), WorktreeError> {
235 let repo_root = repo_root.as_ref();
236 let worktree_path = worktree_path.as_ref();
237
238 if !worktree_path.exists() {
239 return Err(WorktreeError::NotFound(
240 worktree_path.to_string_lossy().to_string(),
241 ));
242 }
243
244 let branch = get_worktree_branch(worktree_path);
246
247 let output = Command::new("git")
249 .args(["worktree", "remove", "--force"])
250 .arg(worktree_path)
251 .current_dir(repo_root)
252 .output()?;
253
254 if !output.status.success() {
255 let stderr = String::from_utf8_lossy(&output.stderr);
256 return Err(WorktreeError::Git(stderr.to_string()));
257 }
258
259 if let Some(branch) = branch
261 && branch.starts_with("ralph/")
262 {
263 let output = Command::new("git")
264 .args(["branch", "-D", &branch])
265 .current_dir(repo_root)
266 .output()?;
267
268 if !output.status.success() {
269 let stderr = String::from_utf8_lossy(&output.stderr);
271 tracing::debug!("Failed to delete branch {}: {}", branch, stderr);
272 }
273 }
274
275 let _ = Command::new("git")
277 .args(["worktree", "prune"])
278 .current_dir(repo_root)
279 .output();
280
281 tracing::debug!("Removed worktree at {}", worktree_path.display());
282
283 Ok(())
284}
285
286pub fn list_worktrees(repo_root: impl AsRef<Path>) -> Result<Vec<Worktree>, WorktreeError> {
296 let repo_root = repo_root.as_ref();
297
298 let output = Command::new("git")
299 .args(["worktree", "list", "--porcelain"])
300 .current_dir(repo_root)
301 .output()?;
302
303 if !output.status.success() {
304 let stderr = String::from_utf8_lossy(&output.stderr);
305 return Err(WorktreeError::Git(stderr.to_string()));
306 }
307
308 let stdout = String::from_utf8_lossy(&output.stdout);
309 parse_worktree_list(&stdout)
310}
311
312fn parse_worktree_list(output: &str) -> Result<Vec<Worktree>, WorktreeError> {
314 let mut worktrees = Vec::new();
315 let mut current_path: Option<PathBuf> = None;
316 let mut current_head: Option<String> = None;
317 let mut current_branch: Option<String> = None;
318 let mut is_bare = false;
319
320 for line in output.lines() {
321 if line.starts_with("worktree ") {
322 if let Some(path) = current_path.take()
324 && !is_bare
325 {
326 worktrees.push(Worktree {
327 path,
328 branch: current_branch
329 .take()
330 .unwrap_or_else(|| "(detached)".to_string()),
331 is_main: worktrees.is_empty(), head: current_head.take(),
333 });
334 }
335
336 current_path = Some(PathBuf::from(line.strip_prefix("worktree ").unwrap()));
337 current_head = None;
338 current_branch = None;
339 is_bare = false;
340 } else if line.starts_with("HEAD ") {
341 current_head = Some(line.strip_prefix("HEAD ").unwrap().to_string());
342 } else if line.starts_with("branch ") {
343 let branch_ref = line.strip_prefix("branch ").unwrap();
345 current_branch = Some(
346 branch_ref
347 .strip_prefix("refs/heads/")
348 .unwrap_or(branch_ref)
349 .to_string(),
350 );
351 } else if line == "bare" {
352 is_bare = true;
353 }
354 }
355
356 if let Some(path) = current_path
358 && !is_bare
359 {
360 worktrees.push(Worktree {
361 path,
362 branch: current_branch.unwrap_or_else(|| "(detached)".to_string()),
363 is_main: worktrees.is_empty(),
364 head: current_head,
365 });
366 }
367
368 Ok(worktrees)
369}
370
371pub fn ensure_gitignore(
380 repo_root: impl AsRef<Path>,
381 worktree_dir: &str,
382) -> Result<(), WorktreeError> {
383 let repo_root = repo_root.as_ref();
384 let gitignore_path = repo_root.join(".gitignore");
385
386 let pattern = if worktree_dir.ends_with('/') {
388 worktree_dir.to_string()
389 } else {
390 format!("{}/", worktree_dir)
391 };
392
393 if gitignore_path.exists() {
395 let file = File::open(&gitignore_path)?;
396 let reader = BufReader::new(file);
397
398 for line in reader.lines() {
399 let line = line?;
400 let trimmed = line.trim();
401
402 if trimmed == pattern || trimmed == pattern.trim_end_matches('/') {
404 tracing::debug!("Pattern {} already in .gitignore", pattern);
405 return Ok(());
406 }
407 }
408 }
409
410 let mut file = OpenOptions::new()
412 .create(true)
413 .append(true)
414 .open(&gitignore_path)?;
415
416 if gitignore_path.exists() {
418 let contents = fs::read_to_string(&gitignore_path)?;
419 if !contents.is_empty() && !contents.ends_with('\n') {
420 writeln!(file)?;
421 }
422 }
423
424 writeln!(file, "{}", pattern)?;
425
426 tracing::debug!("Added {} to .gitignore", pattern);
427
428 Ok(())
429}
430
431fn get_worktree_branch(worktree_path: &Path) -> Option<String> {
433 let output = Command::new("git")
434 .args(["rev-parse", "--abbrev-ref", "HEAD"])
435 .current_dir(worktree_path)
436 .output()
437 .ok()?;
438
439 if output.status.success() {
440 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
441 if branch != "HEAD" {
442 return Some(branch);
443 }
444 }
445 None
446}
447
448fn get_head_commit(worktree_path: &Path) -> Result<String, WorktreeError> {
450 let output = Command::new("git")
451 .args(["rev-parse", "HEAD"])
452 .current_dir(worktree_path)
453 .output()?;
454
455 if output.status.success() {
456 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
457 } else {
458 let stderr = String::from_utf8_lossy(&output.stderr);
459 Err(WorktreeError::Git(stderr.to_string()))
460 }
461}
462
463pub fn list_ralph_worktrees(repo_root: impl AsRef<Path>) -> Result<Vec<Worktree>, WorktreeError> {
465 let all = list_worktrees(repo_root)?;
466 Ok(all
467 .into_iter()
468 .filter(|wt| wt.branch.starts_with("ralph/"))
469 .collect())
470}
471
472pub fn worktree_exists(
474 repo_root: impl AsRef<Path>,
475 loop_id: &str,
476 config: &WorktreeConfig,
477) -> bool {
478 let worktree_path = config.worktree_path(repo_root.as_ref()).join(loop_id);
479 worktree_path.exists()
480}
481
482fn get_untracked_files(repo_root: &Path) -> Result<Vec<PathBuf>, WorktreeError> {
488 let output = Command::new("git")
489 .args(["ls-files", "--others", "--exclude-standard"])
490 .current_dir(repo_root)
491 .output()?;
492
493 if !output.status.success() {
494 let stderr = String::from_utf8_lossy(&output.stderr);
495 return Err(WorktreeError::Git(stderr.to_string()));
496 }
497
498 let stdout = String::from_utf8_lossy(&output.stdout);
499 Ok(stdout
500 .lines()
501 .filter(|line| !line.is_empty())
502 .map(PathBuf::from)
503 .collect())
504}
505
506fn get_unstaged_modified_files(repo_root: &Path) -> Result<Vec<PathBuf>, WorktreeError> {
511 let output = Command::new("git")
512 .args(["diff", "--name-only"])
513 .current_dir(repo_root)
514 .output()?;
515
516 if !output.status.success() {
517 let stderr = String::from_utf8_lossy(&output.stderr);
518 return Err(WorktreeError::Git(stderr.to_string()));
519 }
520
521 let stdout = String::from_utf8_lossy(&output.stdout);
522 Ok(stdout
523 .lines()
524 .filter(|line| !line.is_empty())
525 .map(PathBuf::from)
526 .collect())
527}
528
529fn copy_file_with_structure(
534 repo_root: &Path,
535 worktree_path: &Path,
536 relative_path: &Path,
537) -> Result<bool, WorktreeError> {
538 let source = repo_root.join(relative_path);
539 let dest = worktree_path.join(relative_path);
540
541 if !source.exists() && !source.is_symlink() {
543 return Ok(false);
544 }
545
546 if let Some(parent) = dest.parent() {
548 fs::create_dir_all(parent)?;
549 }
550
551 #[cfg(unix)]
553 {
554 use std::os::unix::fs as unix_fs;
555 if source.is_symlink() {
556 let link_target = fs::read_link(&source)?;
557 if dest.exists() || dest.is_symlink() {
559 fs::remove_file(&dest)?;
560 }
561 unix_fs::symlink(&link_target, &dest)?;
562 return Ok(true);
563 }
564 }
565
566 fs::copy(&source, &dest)?;
568 Ok(true)
569}
570
571pub fn sync_working_directory_to_worktree(
591 repo_root: &Path,
592 worktree_path: &Path,
593 config: &WorktreeConfig,
594) -> Result<SyncStats, WorktreeError> {
595 let mut stats = SyncStats::default();
596
597 let worktree_dir = &config.worktree_dir;
599
600 let should_exclude = |path: &Path| -> bool {
602 let path_str = path.to_string_lossy();
603 if path_str.starts_with(".git/") || path_str == ".git" {
605 return true;
606 }
607 let worktree_dir_str = worktree_dir.to_string_lossy();
609 if path_str.starts_with(&*worktree_dir_str)
610 || path_str.starts_with(&format!("{}/", worktree_dir_str))
611 {
612 return true;
613 }
614 false
615 };
616
617 let untracked = get_untracked_files(repo_root)?;
619 for file in untracked {
620 if should_exclude(&file) {
621 stats.skipped += 1;
622 continue;
623 }
624 match copy_file_with_structure(repo_root, worktree_path, &file) {
625 Ok(true) => {
626 tracing::trace!("Copied untracked file: {}", file.display());
627 stats.untracked_copied += 1;
628 }
629 Ok(false) => {
630 stats.skipped += 1;
631 }
632 Err(e) => {
633 tracing::warn!("Failed to copy untracked file {}: {}", file.display(), e);
634 stats.errors += 1;
635 }
636 }
637 }
638
639 let modified = get_unstaged_modified_files(repo_root)?;
641 for file in modified {
642 if should_exclude(&file) {
643 stats.skipped += 1;
644 continue;
645 }
646 match copy_file_with_structure(repo_root, worktree_path, &file) {
647 Ok(true) => {
648 tracing::trace!("Copied modified file: {}", file.display());
649 stats.modified_copied += 1;
650 }
651 Ok(false) => {
652 stats.skipped += 1;
653 }
654 Err(e) => {
655 tracing::warn!("Failed to copy modified file {}: {}", file.display(), e);
656 stats.errors += 1;
657 }
658 }
659 }
660
661 tracing::debug!(
662 "Synced {} untracked and {} modified files to worktree ({} skipped, {} errors)",
663 stats.untracked_copied,
664 stats.modified_copied,
665 stats.skipped,
666 stats.errors
667 );
668
669 Ok(stats)
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use tempfile::TempDir;
676
677 fn init_git_repo(dir: &Path) {
678 Command::new("git")
679 .args(["init", "--initial-branch=main"])
680 .current_dir(dir)
681 .output()
682 .unwrap();
683
684 Command::new("git")
685 .args(["config", "user.email", "test@test.local"])
686 .current_dir(dir)
687 .output()
688 .unwrap();
689
690 Command::new("git")
691 .args(["config", "user.name", "Test User"])
692 .current_dir(dir)
693 .output()
694 .unwrap();
695
696 fs::write(dir.join("README.md"), "# Test").unwrap();
698 Command::new("git")
699 .args(["add", "README.md"])
700 .current_dir(dir)
701 .output()
702 .unwrap();
703 Command::new("git")
704 .args(["commit", "-m", "Initial commit"])
705 .current_dir(dir)
706 .output()
707 .unwrap();
708 }
709
710 #[test]
711 fn test_worktree_config_default() {
712 let config = WorktreeConfig::default();
713 assert_eq!(config.worktree_dir, PathBuf::from(".worktrees"));
714 }
715
716 #[test]
717 fn test_worktree_config_path() {
718 let config = WorktreeConfig::default();
719 let repo = Path::new("/repo");
720 assert_eq!(
721 config.worktree_path(repo),
722 PathBuf::from("/repo/.worktrees")
723 );
724
725 let absolute_config = WorktreeConfig::with_dir("/tmp/worktrees");
726 assert_eq!(
727 absolute_config.worktree_path(repo),
728 PathBuf::from("/tmp/worktrees")
729 );
730 }
731
732 #[test]
733 fn test_create_and_remove_worktree() {
734 let temp_dir = TempDir::new().unwrap();
735 init_git_repo(temp_dir.path());
736
737 let config = WorktreeConfig::default();
738 let loop_id = "test-loop-123";
739
740 let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
742
743 assert!(worktree.path.exists());
744 assert_eq!(worktree.branch, "ralph/test-loop-123");
745 assert!(!worktree.is_main);
746 assert!(worktree.head.is_some());
747
748 assert!(worktree.path.join("README.md").exists());
750
751 remove_worktree(temp_dir.path(), &worktree.path).unwrap();
753 assert!(!worktree.path.exists());
754 }
755
756 #[test]
757 fn test_create_worktree_already_exists() {
758 let temp_dir = TempDir::new().unwrap();
759 init_git_repo(temp_dir.path());
760
761 let config = WorktreeConfig::default();
762 let loop_id = "duplicate";
763
764 let _wt = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
766
767 let result = create_worktree(temp_dir.path(), loop_id, &config);
769 assert!(matches!(result, Err(WorktreeError::AlreadyExists(_))));
770 }
771
772 #[test]
773 fn test_list_worktrees() {
774 let temp_dir = TempDir::new().unwrap();
775 init_git_repo(temp_dir.path());
776
777 let worktrees = list_worktrees(temp_dir.path()).unwrap();
779 assert_eq!(worktrees.len(), 1);
780 assert!(worktrees[0].is_main);
781
782 let config = WorktreeConfig::default();
784 let _wt = create_worktree(temp_dir.path(), "loop-1", &config).unwrap();
785
786 let worktrees = list_worktrees(temp_dir.path()).unwrap();
787 assert_eq!(worktrees.len(), 2);
788 }
789
790 #[test]
791 fn test_list_ralph_worktrees() {
792 let temp_dir = TempDir::new().unwrap();
793 init_git_repo(temp_dir.path());
794
795 let config = WorktreeConfig::default();
796 let _wt1 = create_worktree(temp_dir.path(), "loop-1", &config).unwrap();
797 let _wt2 = create_worktree(temp_dir.path(), "loop-2", &config).unwrap();
798
799 let ralph_worktrees = list_ralph_worktrees(temp_dir.path()).unwrap();
800 assert_eq!(ralph_worktrees.len(), 2);
801 assert!(
802 ralph_worktrees
803 .iter()
804 .all(|wt| wt.branch.starts_with("ralph/"))
805 );
806 }
807
808 #[test]
809 fn test_ensure_gitignore_new_file() {
810 let temp_dir = TempDir::new().unwrap();
811 let gitignore = temp_dir.path().join(".gitignore");
812
813 assert!(!gitignore.exists());
814
815 ensure_gitignore(temp_dir.path(), ".worktrees").unwrap();
816
817 assert!(gitignore.exists());
818 let contents = fs::read_to_string(&gitignore).unwrap();
819 assert!(contents.contains(".worktrees/"));
820 }
821
822 #[test]
823 fn test_ensure_gitignore_existing_file() {
824 let temp_dir = TempDir::new().unwrap();
825 let gitignore = temp_dir.path().join(".gitignore");
826
827 fs::write(&gitignore, "node_modules/\n").unwrap();
828
829 ensure_gitignore(temp_dir.path(), ".worktrees").unwrap();
830
831 let contents = fs::read_to_string(&gitignore).unwrap();
832 assert!(contents.contains("node_modules/"));
833 assert!(contents.contains(".worktrees/"));
834 }
835
836 #[test]
837 fn test_ensure_gitignore_already_present() {
838 let temp_dir = TempDir::new().unwrap();
839 let gitignore = temp_dir.path().join(".gitignore");
840
841 fs::write(&gitignore, ".worktrees/\n").unwrap();
842
843 ensure_gitignore(temp_dir.path(), ".worktrees").unwrap();
844
845 let contents = fs::read_to_string(&gitignore).unwrap();
846 assert_eq!(contents.matches(".worktrees/").count(), 1);
848 }
849
850 #[test]
851 fn test_ensure_gitignore_without_trailing_slash() {
852 let temp_dir = TempDir::new().unwrap();
853 let gitignore = temp_dir.path().join(".gitignore");
854
855 fs::write(&gitignore, ".worktrees\n").unwrap();
857
858 ensure_gitignore(temp_dir.path(), ".worktrees").unwrap();
859
860 let contents = fs::read_to_string(&gitignore).unwrap();
861 assert!(!contents.contains(".worktrees/\n.worktrees/"));
863 }
864
865 #[test]
866 fn test_worktree_exists() {
867 let temp_dir = TempDir::new().unwrap();
868 init_git_repo(temp_dir.path());
869
870 let config = WorktreeConfig::default();
871 let loop_id = "check-exists";
872
873 assert!(!worktree_exists(temp_dir.path(), loop_id, &config));
874
875 let _wt = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
876
877 assert!(worktree_exists(temp_dir.path(), loop_id, &config));
878 }
879
880 #[test]
881 fn test_not_a_repo() {
882 let temp_dir = TempDir::new().unwrap();
883 let config = WorktreeConfig::default();
886 let result = create_worktree(temp_dir.path(), "loop-1", &config);
887
888 assert!(matches!(result, Err(WorktreeError::NotARepo(_))));
889 }
890
891 #[test]
892 fn test_remove_nonexistent_worktree() {
893 let temp_dir = TempDir::new().unwrap();
894 init_git_repo(temp_dir.path());
895
896 let result = remove_worktree(temp_dir.path(), temp_dir.path().join("nonexistent"));
897
898 assert!(matches!(result, Err(WorktreeError::NotFound(_))));
899 }
900
901 #[test]
902 fn test_parse_worktree_list() {
903 let output = r"worktree /path/to/main
904HEAD abc123def
905branch refs/heads/main
906
907worktree /path/to/.worktrees/loop-1
908HEAD def456ghi
909branch refs/heads/ralph/loop-1
910
911";
912
913 let worktrees = parse_worktree_list(output).unwrap();
914 assert_eq!(worktrees.len(), 2);
915
916 assert_eq!(worktrees[0].path, PathBuf::from("/path/to/main"));
917 assert_eq!(worktrees[0].branch, "main");
918 assert!(worktrees[0].is_main);
919 assert_eq!(worktrees[0].head, Some("abc123def".to_string()));
920
921 assert_eq!(
922 worktrees[1].path,
923 PathBuf::from("/path/to/.worktrees/loop-1")
924 );
925 assert_eq!(worktrees[1].branch, "ralph/loop-1");
926 assert!(!worktrees[1].is_main);
927 }
928
929 #[test]
930 fn test_get_untracked_files() {
931 let temp_dir = TempDir::new().unwrap();
932 init_git_repo(temp_dir.path());
933
934 fs::write(temp_dir.path().join("untracked1.txt"), "content1").unwrap();
936 fs::write(temp_dir.path().join("untracked2.txt"), "content2").unwrap();
937
938 let untracked = get_untracked_files(temp_dir.path()).unwrap();
939 assert_eq!(untracked.len(), 2);
940 assert!(untracked.contains(&PathBuf::from("untracked1.txt")));
941 assert!(untracked.contains(&PathBuf::from("untracked2.txt")));
942 }
943
944 #[test]
945 fn test_get_unstaged_modified_files() {
946 let temp_dir = TempDir::new().unwrap();
947 init_git_repo(temp_dir.path());
948
949 fs::write(temp_dir.path().join("README.md"), "# Modified").unwrap();
951
952 let modified = get_unstaged_modified_files(temp_dir.path()).unwrap();
953 assert_eq!(modified.len(), 1);
954 assert!(modified.contains(&PathBuf::from("README.md")));
955 }
956
957 #[test]
958 fn test_sync_untracked_files_to_worktree() {
959 let temp_dir = TempDir::new().unwrap();
960 init_git_repo(temp_dir.path());
961
962 fs::write(temp_dir.path().join("new_file.txt"), "untracked content").unwrap();
964
965 let config = WorktreeConfig::default();
966 let loop_id = "sync-untracked";
967
968 let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
970
971 let synced_file = worktree.path.join("new_file.txt");
973 assert!(synced_file.exists());
974 assert_eq!(
975 fs::read_to_string(&synced_file).unwrap(),
976 "untracked content"
977 );
978 }
979
980 #[test]
981 fn test_sync_unstaged_changes_to_worktree() {
982 let temp_dir = TempDir::new().unwrap();
983 init_git_repo(temp_dir.path());
984
985 fs::write(temp_dir.path().join("README.md"), "# Modified Content").unwrap();
987
988 let config = WorktreeConfig::default();
989 let loop_id = "sync-modified";
990
991 let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
993
994 let synced_file = worktree.path.join("README.md");
996 assert!(synced_file.exists());
997 assert_eq!(
998 fs::read_to_string(&synced_file).unwrap(),
999 "# Modified Content"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_sync_respects_gitignore() {
1005 let temp_dir = TempDir::new().unwrap();
1006 init_git_repo(temp_dir.path());
1007
1008 fs::write(temp_dir.path().join(".gitignore"), "*.log\n").unwrap();
1010 Command::new("git")
1011 .args(["add", ".gitignore"])
1012 .current_dir(temp_dir.path())
1013 .output()
1014 .unwrap();
1015 Command::new("git")
1016 .args(["commit", "-m", "Add gitignore"])
1017 .current_dir(temp_dir.path())
1018 .output()
1019 .unwrap();
1020
1021 fs::write(temp_dir.path().join("debug.log"), "log content").unwrap();
1023 fs::write(temp_dir.path().join("valid.txt"), "valid content").unwrap();
1025
1026 let config = WorktreeConfig::default();
1027 let loop_id = "sync-gitignore";
1028
1029 let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
1030
1031 assert!(!worktree.path.join("debug.log").exists());
1033 assert!(worktree.path.join("valid.txt").exists());
1035 }
1036
1037 #[test]
1038 fn test_sync_excludes_worktrees_directory() {
1039 let temp_dir = TempDir::new().unwrap();
1040 init_git_repo(temp_dir.path());
1041
1042 let worktrees_dir = temp_dir.path().join(".worktrees");
1044 fs::create_dir_all(&worktrees_dir).unwrap();
1045 fs::write(worktrees_dir.join("should_not_sync.txt"), "content").unwrap();
1046
1047 fs::write(temp_dir.path().join("should_sync.txt"), "content").unwrap();
1049
1050 let config = WorktreeConfig::default();
1051 let loop_id = "sync-exclude-worktrees";
1052
1053 let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
1054
1055 assert!(worktree.path.join("should_sync.txt").exists());
1057 assert!(
1060 !worktree
1061 .path
1062 .join(".worktrees/should_not_sync.txt")
1063 .exists()
1064 );
1065 }
1066
1067 #[test]
1068 #[cfg(unix)]
1069 fn test_sync_preserves_symlinks() {
1070 use std::os::unix::fs as unix_fs;
1071
1072 let temp_dir = TempDir::new().unwrap();
1073 init_git_repo(temp_dir.path());
1074
1075 fs::write(temp_dir.path().join("target.txt"), "target content").unwrap();
1077 Command::new("git")
1078 .args(["add", "target.txt"])
1079 .current_dir(temp_dir.path())
1080 .output()
1081 .unwrap();
1082 Command::new("git")
1083 .args(["commit", "-m", "Add target"])
1084 .current_dir(temp_dir.path())
1085 .output()
1086 .unwrap();
1087
1088 unix_fs::symlink("target.txt", temp_dir.path().join("link.txt")).unwrap();
1090
1091 let config = WorktreeConfig::default();
1092 let loop_id = "sync-symlinks";
1093
1094 let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
1095
1096 let synced_link = worktree.path.join("link.txt");
1098 assert!(synced_link.is_symlink());
1099 assert_eq!(
1100 fs::read_link(&synced_link).unwrap(),
1101 PathBuf::from("target.txt")
1102 );
1103 }
1104
1105 #[test]
1106 fn test_sync_handles_binary_files() {
1107 let temp_dir = TempDir::new().unwrap();
1108 init_git_repo(temp_dir.path());
1109
1110 let binary_content: Vec<u8> = vec![
1112 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ];
1115 fs::write(temp_dir.path().join("image.png"), &binary_content).unwrap();
1116
1117 let config = WorktreeConfig::default();
1118 let loop_id = "sync-binary";
1119
1120 let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
1121
1122 let synced_file = worktree.path.join("image.png");
1124 assert!(synced_file.exists());
1125 assert_eq!(fs::read(&synced_file).unwrap(), binary_content);
1126 }
1127
1128 #[test]
1129 fn test_sync_handles_nested_directories() {
1130 let temp_dir = TempDir::new().unwrap();
1131 init_git_repo(temp_dir.path());
1132
1133 let nested_dir = temp_dir.path().join("src/components/nested");
1135 fs::create_dir_all(&nested_dir).unwrap();
1136 fs::write(nested_dir.join("deep.txt"), "deep content").unwrap();
1137
1138 let config = WorktreeConfig::default();
1139 let loop_id = "sync-nested";
1140
1141 let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
1142
1143 let synced_file = worktree.path.join("src/components/nested/deep.txt");
1145 assert!(synced_file.exists());
1146 assert_eq!(fs::read_to_string(&synced_file).unwrap(), "deep content");
1147 }
1148
1149 #[test]
1150 fn test_sync_stats_returned() {
1151 let temp_dir = TempDir::new().unwrap();
1152 init_git_repo(temp_dir.path());
1153
1154 fs::write(temp_dir.path().join("untracked1.txt"), "content").unwrap();
1156 fs::write(temp_dir.path().join("untracked2.txt"), "content").unwrap();
1157
1158 fs::write(temp_dir.path().join("README.md"), "# Modified").unwrap();
1160
1161 let config = WorktreeConfig::default();
1162
1163 let worktree_path = temp_dir.path().join(".worktrees/stats-test");
1165 fs::create_dir_all(&worktree_path).unwrap();
1166
1167 let stats =
1168 sync_working_directory_to_worktree(temp_dir.path(), &worktree_path, &config).unwrap();
1169
1170 assert_eq!(stats.untracked_copied, 2);
1171 assert_eq!(stats.modified_copied, 1);
1172 assert_eq!(stats.errors, 0);
1173 }
1174}