Skip to main content

ralph_core/
worktree.rs

1//! Git worktree management for parallel Ralph loops.
2//!
3//! Provides filesystem isolation for concurrent loops using git worktrees.
4//! Each parallel loop gets its own working directory with full filesystem
5//! isolation, sharing only `.git` history. Conflicts are resolved at merge time.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use ralph_core::worktree::{Worktree, WorktreeConfig, create_worktree, remove_worktree, list_worktrees};
11//!
12//! fn main() -> Result<(), Box<dyn std::error::Error>> {
13//!     let config = WorktreeConfig::default();
14//!
15//!     // Create worktree for a parallel loop
16//!     let worktree = create_worktree(".", "ralph-20250124-a3f2", &config)?;
17//!     println!("Created worktree at: {}", worktree.path.display());
18//!
19//!     // List all worktrees
20//!     let worktrees = list_worktrees(".")?;
21//!     for wt in worktrees {
22//!         println!("  {}: {}", wt.branch, wt.path.display());
23//!     }
24//!
25//!     // Clean up when done
26//!     remove_worktree(".", &worktree.path)?;
27//!     Ok(())
28//! }
29//! ```
30
31use std::fs::{self, File, OpenOptions};
32use std::io::{self, BufRead, BufReader, Write};
33use std::path::{Path, PathBuf};
34use std::process::Command;
35
36/// Configuration for worktree operations.
37#[derive(Debug, Clone)]
38pub struct WorktreeConfig {
39    /// Directory where worktrees are created (default: `.worktrees`).
40    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    /// Create config with custom worktree directory.
53    pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
54        Self {
55            worktree_dir: dir.into(),
56        }
57    }
58
59    /// Get the absolute path to worktree directory relative to repo root.
60    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/// Information about a git worktree.
70#[derive(Debug, Clone)]
71pub struct Worktree {
72    /// Absolute path to the worktree directory.
73    pub path: PathBuf,
74
75    /// The branch checked out in this worktree.
76    pub branch: String,
77
78    /// Whether this is the main worktree.
79    pub is_main: bool,
80
81    /// HEAD commit (if available).
82    pub head: Option<String>,
83}
84
85/// Statistics about files synced to a worktree.
86#[derive(Debug, Default, Clone)]
87pub struct SyncStats {
88    /// Number of untracked files copied.
89    pub untracked_copied: usize,
90    /// Number of modified (unstaged) files copied.
91    pub modified_copied: usize,
92    /// Number of files skipped (e.g., no longer exists).
93    pub skipped: usize,
94    /// Number of files that failed to copy.
95    pub errors: usize,
96}
97
98/// Errors that can occur during worktree operations.
99#[derive(Debug, thiserror::Error)]
100pub enum WorktreeError {
101    /// IO error.
102    #[error("IO error: {0}")]
103    Io(#[from] io::Error),
104
105    /// Git command failed.
106    #[error("Git command failed: {0}")]
107    Git(String),
108
109    /// Worktree already exists.
110    #[error("Worktree already exists: {0}")]
111    AlreadyExists(String),
112
113    /// Worktree not found.
114    #[error("Worktree not found: {0}")]
115    NotFound(String),
116
117    /// Not a git repository.
118    #[error("Not a git repository: {0}")]
119    NotARepo(String),
120
121    /// Branch already exists.
122    #[error("Branch already exists: {0}")]
123    BranchExists(String),
124}
125
126/// Create a new worktree for a parallel Ralph loop.
127///
128/// Creates a new branch and worktree at `{config.worktree_dir}/{loop_id}`.
129/// The branch is created from HEAD of the current branch.
130///
131/// # Arguments
132///
133/// * `repo_root` - Root of the git repository
134/// * `loop_id` - Unique identifier for the loop (e.g., "ralph-20250124-a3f2")
135/// * `config` - Worktree configuration
136///
137/// # Returns
138///
139/// Information about the created worktree.
140pub 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    // Verify this is a git repository
148    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    // Check if worktree already exists
159    if worktree_path.exists() {
160        return Err(WorktreeError::AlreadyExists(
161            worktree_path.to_string_lossy().to_string(),
162        ));
163    }
164
165    // Ensure worktree directory exists
166    fs::create_dir_all(&worktree_base)?;
167
168    // Create worktree with new branch
169    // git worktree add -b <branch> <path>
170    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        // Check for specific error cases
180        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    // Sync untracked files and unstaged changes
193    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    // Get the HEAD commit
203    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
221/// Remove a worktree and optionally its branch.
222///
223/// # Arguments
224///
225/// * `repo_root` - Root of the git repository
226/// * `worktree_path` - Path to the worktree to remove
227///
228/// # Note
229///
230/// This also deletes the associated branch if it exists.
231pub 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    // Get the branch name before removing
245    let branch = get_worktree_branch(worktree_path);
246
247    // Remove the worktree (--force handles uncommitted changes)
248    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    // Delete the branch if it was a ralph/* branch
260    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            // Non-fatal: branch might already be deleted
270            let stderr = String::from_utf8_lossy(&output.stderr);
271            tracing::debug!("Failed to delete branch {}: {}", branch, stderr);
272        }
273    }
274
275    // Prune worktree refs
276    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
286/// List all git worktrees in the repository.
287///
288/// # Arguments
289///
290/// * `repo_root` - Root of the git repository (can be any worktree)
291///
292/// # Returns
293///
294/// List of all worktrees, including the main worktree.
295pub 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
312/// Parse the porcelain output of `git worktree list`.
313fn 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            // Save previous worktree if any
323            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(), // First one is main
332                    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            // Branch is in format "refs/heads/branch-name"
344            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    // Don't forget the last one
357    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
371/// Ensure the worktree directory is in `.gitignore`.
372///
373/// Appends the pattern to `.gitignore` if not already present.
374///
375/// # Arguments
376///
377/// * `repo_root` - Root of the git repository
378/// * `worktree_dir` - The worktree directory pattern to ignore (e.g., ".worktrees")
379pub 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    // Pattern to add (with trailing slash for directory)
387    let pattern = if worktree_dir.ends_with('/') {
388        worktree_dir.to_string()
389    } else {
390        format!("{}/", worktree_dir)
391    };
392
393    // Check if pattern already exists
394    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            // Check if this line matches our pattern (with or without trailing slash)
403            if trimmed == pattern || trimmed == pattern.trim_end_matches('/') {
404                tracing::debug!("Pattern {} already in .gitignore", pattern);
405                return Ok(());
406            }
407        }
408    }
409
410    // Append the pattern
411    let mut file = OpenOptions::new()
412        .create(true)
413        .append(true)
414        .open(&gitignore_path)?;
415
416    // Add newline before if file exists and doesn't end with newline
417    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
431/// Get the branch name for a worktree.
432fn 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
448/// Get the HEAD commit SHA for a worktree.
449fn 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
463/// Get the list of Ralph-specific worktrees (those with `ralph/` branches).
464pub 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
472/// Check if a worktree exists for the given loop ID.
473pub 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
482/// Get list of untracked files in the repository.
483///
484/// Uses `git ls-files --others --exclude-standard` to get files that are:
485/// - Not tracked by git
486/// - Not ignored by .gitignore
487fn 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
506/// Get list of tracked files with unstaged modifications.
507///
508/// Uses `git diff --name-only` to get files that have been modified
509/// but not yet staged for commit.
510fn 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
529/// Copy a file from repo to worktree, preserving directory structure.
530///
531/// Creates parent directories as needed. Handles symlinks on Unix.
532/// Returns Ok(false) if the source file no longer exists (race condition).
533fn 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    // Skip if source no longer exists (race condition)
542    if !source.exists() && !source.is_symlink() {
543        return Ok(false);
544    }
545
546    // Create parent directories
547    if let Some(parent) = dest.parent() {
548        fs::create_dir_all(parent)?;
549    }
550
551    // Handle symlinks on Unix
552    #[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            // Remove existing file/symlink if present
558            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    // Copy regular file (handles binary files correctly)
567    fs::copy(&source, &dest)?;
568    Ok(true)
569}
570
571/// Sync untracked and unstaged files from the main repo to a worktree.
572///
573/// This copies files that are not committed to git, ensuring that WIP files
574/// and uncommitted changes are available in the worktree for parallel loops.
575///
576/// # Exclusions
577///
578/// - `.git/` directory (never copied)
579/// - The worktree directory itself (e.g., `.worktrees/`)
580///
581/// # Arguments
582///
583/// * `repo_root` - Root of the git repository
584/// * `worktree_path` - Path to the target worktree
585/// * `config` - Worktree configuration (for determining exclusion paths)
586///
587/// # Returns
588///
589/// Statistics about what was synced.
590pub 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    // Get the worktree directory name for exclusion
598    let worktree_dir = &config.worktree_dir;
599
600    // Helper to check if a path should be excluded
601    let should_exclude = |path: &Path| -> bool {
602        let path_str = path.to_string_lossy();
603        // Exclude .git directory
604        if path_str.starts_with(".git/") || path_str == ".git" {
605            return true;
606        }
607        // Exclude the worktree directory itself
608        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    // Get untracked files
618    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    // Get unstaged modified files
640    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        // Create initial commit (required for worktrees)
697        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        // Create worktree
741        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        // Verify README was copied
749        assert!(worktree.path.join("README.md").exists());
750
751        // Remove worktree
752        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        // Create first worktree
765        let _wt = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
766
767        // Try to create duplicate
768        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        // Initially just the main worktree
778        let worktrees = list_worktrees(temp_dir.path()).unwrap();
779        assert_eq!(worktrees.len(), 1);
780        assert!(worktrees[0].is_main);
781
782        // Add a worktree
783        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        // Should only appear once
847        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        // Existing pattern without trailing slash
856        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        // Should not add duplicate
862        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        // Don't init git
884
885        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        // Create untracked files
935        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        // Modify a tracked file without staging
950        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        // Create an untracked file
963        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        // Create worktree - should sync untracked file
969        let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
970
971        // Verify untracked file was copied
972        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        // Modify a tracked file without staging
986        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        // Create worktree - should sync modified file
992        let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
993
994        // Verify modified content was copied (overwrote the committed version)
995        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        // Add a pattern to .gitignore
1009        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        // Create an ignored file
1022        fs::write(temp_dir.path().join("debug.log"), "log content").unwrap();
1023        // Create a non-ignored file
1024        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        // Ignored file should NOT be copied (git ls-files --others --exclude-standard respects .gitignore)
1032        assert!(!worktree.path.join("debug.log").exists());
1033        // Non-ignored file should be copied
1034        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        // Create an untracked file in the worktrees directory manually
1043        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        // Create a normal untracked file
1048        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        // Normal file should be synced
1056        assert!(worktree.path.join("should_sync.txt").exists());
1057        // The .worktrees directory should NOT be synced into itself
1058        // (this would cause recursion issues)
1059        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        // Create a target file
1076        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        // Create an untracked symlink
1089        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        // Verify symlink was preserved
1097        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        // Create a binary file (PNG header bytes)
1111        let binary_content: Vec<u8> = vec![
1112            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
1113            0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
1114        ];
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        // Verify binary file was copied correctly
1123        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        // Create nested untracked files
1134        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        // Verify nested file was copied with correct directory structure
1144        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        // Create untracked files
1155        fs::write(temp_dir.path().join("untracked1.txt"), "content").unwrap();
1156        fs::write(temp_dir.path().join("untracked2.txt"), "content").unwrap();
1157
1158        // Modify a tracked file
1159        fs::write(temp_dir.path().join("README.md"), "# Modified").unwrap();
1160
1161        let config = WorktreeConfig::default();
1162
1163        // Test sync_working_directory_to_worktree directly
1164        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}