use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct WorktreeConfig {
pub worktree_dir: PathBuf,
}
impl Default for WorktreeConfig {
fn default() -> Self {
Self {
worktree_dir: PathBuf::from(".worktrees"),
}
}
}
impl WorktreeConfig {
pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
Self {
worktree_dir: dir.into(),
}
}
pub fn worktree_path(&self, repo_root: &Path) -> PathBuf {
if self.worktree_dir.is_absolute() {
self.worktree_dir.clone()
} else {
repo_root.join(&self.worktree_dir)
}
}
}
#[derive(Debug, Clone)]
pub struct Worktree {
pub path: PathBuf,
pub branch: String,
pub is_main: bool,
pub head: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub struct SyncStats {
pub untracked_copied: usize,
pub modified_copied: usize,
pub skipped: usize,
pub errors: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum WorktreeError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Git command failed: {0}")]
Git(String),
#[error("Worktree already exists: {0}")]
AlreadyExists(String),
#[error("Worktree not found: {0}")]
NotFound(String),
#[error("Not a git repository: {0}")]
NotARepo(String),
#[error("Branch already exists: {0}")]
BranchExists(String),
}
pub fn create_worktree(
repo_root: impl AsRef<Path>,
loop_id: &str,
config: &WorktreeConfig,
) -> Result<Worktree, WorktreeError> {
let repo_root = repo_root.as_ref();
if !repo_root.join(".git").exists() && !repo_root.join(".git").is_file() {
return Err(WorktreeError::NotARepo(
repo_root.to_string_lossy().to_string(),
));
}
let worktree_base = config.worktree_path(repo_root);
let worktree_path = worktree_base.join(loop_id);
let branch_name = format!("ralph/{loop_id}");
if worktree_path.exists() {
return Err(WorktreeError::AlreadyExists(
worktree_path.to_string_lossy().to_string(),
));
}
fs::create_dir_all(&worktree_base)?;
let output = Command::new("git")
.args(["worktree", "add", "-b", &branch_name])
.arg(&worktree_path)
.current_dir(repo_root)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
if stderr.contains("branch") {
return Err(WorktreeError::BranchExists(branch_name));
}
return Err(WorktreeError::AlreadyExists(
worktree_path.to_string_lossy().to_string(),
));
}
return Err(WorktreeError::Git(stderr.to_string()));
}
let sync_stats = sync_working_directory_to_worktree(repo_root, &worktree_path, config)?;
if sync_stats.errors > 0 {
tracing::warn!(
"Some files failed to sync to worktree: {} errors",
sync_stats.errors
);
}
let head = get_head_commit(&worktree_path).ok();
tracing::debug!(
"Created worktree at {} on branch {} (synced {} untracked, {} modified files)",
worktree_path.display(),
branch_name,
sync_stats.untracked_copied,
sync_stats.modified_copied
);
Ok(Worktree {
path: worktree_path,
branch: branch_name,
is_main: false,
head,
})
}
pub fn remove_worktree(
repo_root: impl AsRef<Path>,
worktree_path: impl AsRef<Path>,
) -> Result<(), WorktreeError> {
let repo_root = repo_root.as_ref();
let worktree_path = worktree_path.as_ref();
if !worktree_path.exists() {
return Err(WorktreeError::NotFound(
worktree_path.to_string_lossy().to_string(),
));
}
let branch = get_worktree_branch(worktree_path);
let output = Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(worktree_path)
.current_dir(repo_root)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::Git(stderr.to_string()));
}
if let Some(branch) = branch
&& branch.starts_with("ralph/")
{
let output = Command::new("git")
.args(["branch", "-D", &branch])
.current_dir(repo_root)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::debug!("Failed to delete branch {}: {}", branch, stderr);
}
}
let _ = Command::new("git")
.args(["worktree", "prune"])
.current_dir(repo_root)
.output();
tracing::debug!("Removed worktree at {}", worktree_path.display());
Ok(())
}
pub fn list_worktrees(repo_root: impl AsRef<Path>) -> Result<Vec<Worktree>, WorktreeError> {
let repo_root = repo_root.as_ref();
let output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(repo_root)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::Git(stderr.to_string()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_worktree_list(&stdout)
}
fn parse_worktree_list(output: &str) -> Result<Vec<Worktree>, WorktreeError> {
let mut worktrees = Vec::new();
let mut current_path: Option<PathBuf> = None;
let mut current_head: Option<String> = None;
let mut current_branch: Option<String> = None;
let mut is_bare = false;
for line in output.lines() {
if line.starts_with("worktree ") {
if let Some(path) = current_path.take()
&& !is_bare
{
worktrees.push(Worktree {
path,
branch: current_branch
.take()
.unwrap_or_else(|| "(detached)".to_string()),
is_main: worktrees.is_empty(), head: current_head.take(),
});
}
current_path = Some(PathBuf::from(line.strip_prefix("worktree ").unwrap()));
current_head = None;
current_branch = None;
is_bare = false;
} else if line.starts_with("HEAD ") {
current_head = Some(line.strip_prefix("HEAD ").unwrap().to_string());
} else if line.starts_with("branch ") {
let branch_ref = line.strip_prefix("branch ").unwrap();
current_branch = Some(
branch_ref
.strip_prefix("refs/heads/")
.unwrap_or(branch_ref)
.to_string(),
);
} else if line == "bare" {
is_bare = true;
}
}
if let Some(path) = current_path
&& !is_bare
{
worktrees.push(Worktree {
path,
branch: current_branch.unwrap_or_else(|| "(detached)".to_string()),
is_main: worktrees.is_empty(),
head: current_head,
});
}
Ok(worktrees)
}
pub fn ensure_gitignore(
repo_root: impl AsRef<Path>,
worktree_dir: &str,
) -> Result<(), WorktreeError> {
let repo_root = repo_root.as_ref();
let gitignore_path = repo_root.join(".gitignore");
let pattern = if worktree_dir.ends_with('/') {
worktree_dir.to_string()
} else {
format!("{}/", worktree_dir)
};
if gitignore_path.exists() {
let file = File::open(&gitignore_path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed == pattern || trimmed == pattern.trim_end_matches('/') {
tracing::debug!("Pattern {} already in .gitignore", pattern);
return Ok(());
}
}
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore_path)?;
if gitignore_path.exists() {
let contents = fs::read_to_string(&gitignore_path)?;
if !contents.is_empty() && !contents.ends_with('\n') {
writeln!(file)?;
}
}
writeln!(file, "{}", pattern)?;
tracing::debug!("Added {} to .gitignore", pattern);
Ok(())
}
fn get_worktree_branch(worktree_path: &Path) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(worktree_path)
.output()
.ok()?;
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch != "HEAD" {
return Some(branch);
}
}
None
}
fn get_head_commit(worktree_path: &Path) -> Result<String, WorktreeError> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(worktree_path)
.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(WorktreeError::Git(stderr.to_string()))
}
}
pub fn list_ralph_worktrees(repo_root: impl AsRef<Path>) -> Result<Vec<Worktree>, WorktreeError> {
let all = list_worktrees(repo_root)?;
Ok(all
.into_iter()
.filter(|wt| wt.branch.starts_with("ralph/"))
.collect())
}
pub fn worktree_exists(
repo_root: impl AsRef<Path>,
loop_id: &str,
config: &WorktreeConfig,
) -> bool {
let worktree_path = config.worktree_path(repo_root.as_ref()).join(loop_id);
worktree_path.exists()
}
fn get_untracked_files(repo_root: &Path) -> Result<Vec<PathBuf>, WorktreeError> {
let output = Command::new("git")
.args(["ls-files", "--others", "--exclude-standard"])
.current_dir(repo_root)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::Git(stderr.to_string()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect())
}
fn get_unstaged_modified_files(repo_root: &Path) -> Result<Vec<PathBuf>, WorktreeError> {
let output = Command::new("git")
.args(["diff", "--name-only"])
.current_dir(repo_root)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WorktreeError::Git(stderr.to_string()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect())
}
fn copy_file_with_structure(
repo_root: &Path,
worktree_path: &Path,
relative_path: &Path,
) -> Result<bool, WorktreeError> {
let source = repo_root.join(relative_path);
let dest = worktree_path.join(relative_path);
if !source.exists() && !source.is_symlink() {
return Ok(false);
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::os::unix::fs as unix_fs;
if source.is_symlink() {
let link_target = fs::read_link(&source)?;
if dest.exists() || dest.is_symlink() {
fs::remove_file(&dest)?;
}
unix_fs::symlink(&link_target, &dest)?;
return Ok(true);
}
}
fs::copy(&source, &dest)?;
Ok(true)
}
pub fn sync_working_directory_to_worktree(
repo_root: &Path,
worktree_path: &Path,
config: &WorktreeConfig,
) -> Result<SyncStats, WorktreeError> {
let mut stats = SyncStats::default();
let worktree_dir = &config.worktree_dir;
let should_exclude = |path: &Path| -> bool {
let path_str = path.to_string_lossy();
if path_str.starts_with(".git/") || path_str == ".git" {
return true;
}
let worktree_dir_str = worktree_dir.to_string_lossy();
if path_str.starts_with(&*worktree_dir_str)
|| path_str.starts_with(&format!("{}/", worktree_dir_str))
{
return true;
}
false
};
let untracked = get_untracked_files(repo_root)?;
for file in untracked {
if should_exclude(&file) {
stats.skipped += 1;
continue;
}
match copy_file_with_structure(repo_root, worktree_path, &file) {
Ok(true) => {
tracing::trace!("Copied untracked file: {}", file.display());
stats.untracked_copied += 1;
}
Ok(false) => {
stats.skipped += 1;
}
Err(e) => {
tracing::warn!("Failed to copy untracked file {}: {}", file.display(), e);
stats.errors += 1;
}
}
}
let modified = get_unstaged_modified_files(repo_root)?;
for file in modified {
if should_exclude(&file) {
stats.skipped += 1;
continue;
}
match copy_file_with_structure(repo_root, worktree_path, &file) {
Ok(true) => {
tracing::trace!("Copied modified file: {}", file.display());
stats.modified_copied += 1;
}
Ok(false) => {
stats.skipped += 1;
}
Err(e) => {
tracing::warn!("Failed to copy modified file {}: {}", file.display(), e);
stats.errors += 1;
}
}
}
tracing::debug!(
"Synced {} untracked and {} modified files to worktree ({} skipped, {} errors)",
stats.untracked_copied,
stats.modified_copied,
stats.skipped,
stats.errors
);
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn init_git_repo(dir: &Path) {
Command::new("git")
.args(["init", "--initial-branch=main"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.local"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.unwrap();
fs::write(dir.join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(dir)
.output()
.unwrap();
}
#[test]
fn test_worktree_config_default() {
let config = WorktreeConfig::default();
assert_eq!(config.worktree_dir, PathBuf::from(".worktrees"));
}
#[test]
fn test_worktree_config_path() {
let config = WorktreeConfig::default();
let repo = Path::new("/repo");
assert_eq!(
config.worktree_path(repo),
PathBuf::from("/repo/.worktrees")
);
let absolute_config = WorktreeConfig::with_dir("/tmp/worktrees");
assert_eq!(
absolute_config.worktree_path(repo),
PathBuf::from("/tmp/worktrees")
);
}
#[test]
fn test_create_and_remove_worktree() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
let config = WorktreeConfig::default();
let loop_id = "test-loop-123";
let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
assert!(worktree.path.exists());
assert_eq!(worktree.branch, "ralph/test-loop-123");
assert!(!worktree.is_main);
assert!(worktree.head.is_some());
assert!(worktree.path.join("README.md").exists());
remove_worktree(temp_dir.path(), &worktree.path).unwrap();
assert!(!worktree.path.exists());
}
#[test]
fn test_create_worktree_already_exists() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
let config = WorktreeConfig::default();
let loop_id = "duplicate";
let _wt = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
let result = create_worktree(temp_dir.path(), loop_id, &config);
assert!(matches!(result, Err(WorktreeError::AlreadyExists(_))));
}
#[test]
fn test_list_worktrees() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
let worktrees = list_worktrees(temp_dir.path()).unwrap();
assert_eq!(worktrees.len(), 1);
assert!(worktrees[0].is_main);
let config = WorktreeConfig::default();
let _wt = create_worktree(temp_dir.path(), "loop-1", &config).unwrap();
let worktrees = list_worktrees(temp_dir.path()).unwrap();
assert_eq!(worktrees.len(), 2);
}
#[test]
fn test_list_ralph_worktrees() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
let config = WorktreeConfig::default();
let _wt1 = create_worktree(temp_dir.path(), "loop-1", &config).unwrap();
let _wt2 = create_worktree(temp_dir.path(), "loop-2", &config).unwrap();
let ralph_worktrees = list_ralph_worktrees(temp_dir.path()).unwrap();
assert_eq!(ralph_worktrees.len(), 2);
assert!(
ralph_worktrees
.iter()
.all(|wt| wt.branch.starts_with("ralph/"))
);
}
#[test]
fn test_ensure_gitignore_new_file() {
let temp_dir = TempDir::new().unwrap();
let gitignore = temp_dir.path().join(".gitignore");
assert!(!gitignore.exists());
ensure_gitignore(temp_dir.path(), ".worktrees").unwrap();
assert!(gitignore.exists());
let contents = fs::read_to_string(&gitignore).unwrap();
assert!(contents.contains(".worktrees/"));
}
#[test]
fn test_ensure_gitignore_existing_file() {
let temp_dir = TempDir::new().unwrap();
let gitignore = temp_dir.path().join(".gitignore");
fs::write(&gitignore, "node_modules/\n").unwrap();
ensure_gitignore(temp_dir.path(), ".worktrees").unwrap();
let contents = fs::read_to_string(&gitignore).unwrap();
assert!(contents.contains("node_modules/"));
assert!(contents.contains(".worktrees/"));
}
#[test]
fn test_ensure_gitignore_already_present() {
let temp_dir = TempDir::new().unwrap();
let gitignore = temp_dir.path().join(".gitignore");
fs::write(&gitignore, ".worktrees/\n").unwrap();
ensure_gitignore(temp_dir.path(), ".worktrees").unwrap();
let contents = fs::read_to_string(&gitignore).unwrap();
assert_eq!(contents.matches(".worktrees/").count(), 1);
}
#[test]
fn test_ensure_gitignore_without_trailing_slash() {
let temp_dir = TempDir::new().unwrap();
let gitignore = temp_dir.path().join(".gitignore");
fs::write(&gitignore, ".worktrees\n").unwrap();
ensure_gitignore(temp_dir.path(), ".worktrees").unwrap();
let contents = fs::read_to_string(&gitignore).unwrap();
assert!(!contents.contains(".worktrees/\n.worktrees/"));
}
#[test]
fn test_worktree_exists() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
let config = WorktreeConfig::default();
let loop_id = "check-exists";
assert!(!worktree_exists(temp_dir.path(), loop_id, &config));
let _wt = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
assert!(worktree_exists(temp_dir.path(), loop_id, &config));
}
#[test]
fn test_not_a_repo() {
let temp_dir = TempDir::new().unwrap();
let config = WorktreeConfig::default();
let result = create_worktree(temp_dir.path(), "loop-1", &config);
assert!(matches!(result, Err(WorktreeError::NotARepo(_))));
}
#[test]
fn test_remove_nonexistent_worktree() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
let result = remove_worktree(temp_dir.path(), temp_dir.path().join("nonexistent"));
assert!(matches!(result, Err(WorktreeError::NotFound(_))));
}
#[test]
fn test_parse_worktree_list() {
let output = r"worktree /path/to/main
HEAD abc123def
branch refs/heads/main
worktree /path/to/.worktrees/loop-1
HEAD def456ghi
branch refs/heads/ralph/loop-1
";
let worktrees = parse_worktree_list(output).unwrap();
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[0].path, PathBuf::from("/path/to/main"));
assert_eq!(worktrees[0].branch, "main");
assert!(worktrees[0].is_main);
assert_eq!(worktrees[0].head, Some("abc123def".to_string()));
assert_eq!(
worktrees[1].path,
PathBuf::from("/path/to/.worktrees/loop-1")
);
assert_eq!(worktrees[1].branch, "ralph/loop-1");
assert!(!worktrees[1].is_main);
}
#[test]
fn test_get_untracked_files() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
fs::write(temp_dir.path().join("untracked1.txt"), "content1").unwrap();
fs::write(temp_dir.path().join("untracked2.txt"), "content2").unwrap();
let untracked = get_untracked_files(temp_dir.path()).unwrap();
assert_eq!(untracked.len(), 2);
assert!(untracked.contains(&PathBuf::from("untracked1.txt")));
assert!(untracked.contains(&PathBuf::from("untracked2.txt")));
}
#[test]
fn test_get_unstaged_modified_files() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
fs::write(temp_dir.path().join("README.md"), "# Modified").unwrap();
let modified = get_unstaged_modified_files(temp_dir.path()).unwrap();
assert_eq!(modified.len(), 1);
assert!(modified.contains(&PathBuf::from("README.md")));
}
#[test]
fn test_sync_untracked_files_to_worktree() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
fs::write(temp_dir.path().join("new_file.txt"), "untracked content").unwrap();
let config = WorktreeConfig::default();
let loop_id = "sync-untracked";
let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
let synced_file = worktree.path.join("new_file.txt");
assert!(synced_file.exists());
assert_eq!(
fs::read_to_string(&synced_file).unwrap(),
"untracked content"
);
}
#[test]
fn test_sync_unstaged_changes_to_worktree() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
fs::write(temp_dir.path().join("README.md"), "# Modified Content").unwrap();
let config = WorktreeConfig::default();
let loop_id = "sync-modified";
let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
let synced_file = worktree.path.join("README.md");
assert!(synced_file.exists());
assert_eq!(
fs::read_to_string(&synced_file).unwrap(),
"# Modified Content"
);
}
#[test]
fn test_sync_respects_gitignore() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
fs::write(temp_dir.path().join(".gitignore"), "*.log\n").unwrap();
Command::new("git")
.args(["add", ".gitignore"])
.current_dir(temp_dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add gitignore"])
.current_dir(temp_dir.path())
.output()
.unwrap();
fs::write(temp_dir.path().join("debug.log"), "log content").unwrap();
fs::write(temp_dir.path().join("valid.txt"), "valid content").unwrap();
let config = WorktreeConfig::default();
let loop_id = "sync-gitignore";
let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
assert!(!worktree.path.join("debug.log").exists());
assert!(worktree.path.join("valid.txt").exists());
}
#[test]
fn test_sync_excludes_worktrees_directory() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
let worktrees_dir = temp_dir.path().join(".worktrees");
fs::create_dir_all(&worktrees_dir).unwrap();
fs::write(worktrees_dir.join("should_not_sync.txt"), "content").unwrap();
fs::write(temp_dir.path().join("should_sync.txt"), "content").unwrap();
let config = WorktreeConfig::default();
let loop_id = "sync-exclude-worktrees";
let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
assert!(worktree.path.join("should_sync.txt").exists());
assert!(
!worktree
.path
.join(".worktrees/should_not_sync.txt")
.exists()
);
}
#[test]
#[cfg(unix)]
fn test_sync_preserves_symlinks() {
use std::os::unix::fs as unix_fs;
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
fs::write(temp_dir.path().join("target.txt"), "target content").unwrap();
Command::new("git")
.args(["add", "target.txt"])
.current_dir(temp_dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add target"])
.current_dir(temp_dir.path())
.output()
.unwrap();
unix_fs::symlink("target.txt", temp_dir.path().join("link.txt")).unwrap();
let config = WorktreeConfig::default();
let loop_id = "sync-symlinks";
let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
let synced_link = worktree.path.join("link.txt");
assert!(synced_link.is_symlink());
assert_eq!(
fs::read_link(&synced_link).unwrap(),
PathBuf::from("target.txt")
);
}
#[test]
fn test_sync_handles_binary_files() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
let binary_content: Vec<u8> = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ];
fs::write(temp_dir.path().join("image.png"), &binary_content).unwrap();
let config = WorktreeConfig::default();
let loop_id = "sync-binary";
let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
let synced_file = worktree.path.join("image.png");
assert!(synced_file.exists());
assert_eq!(fs::read(&synced_file).unwrap(), binary_content);
}
#[test]
fn test_sync_handles_nested_directories() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
let nested_dir = temp_dir.path().join("src/components/nested");
fs::create_dir_all(&nested_dir).unwrap();
fs::write(nested_dir.join("deep.txt"), "deep content").unwrap();
let config = WorktreeConfig::default();
let loop_id = "sync-nested";
let worktree = create_worktree(temp_dir.path(), loop_id, &config).unwrap();
let synced_file = worktree.path.join("src/components/nested/deep.txt");
assert!(synced_file.exists());
assert_eq!(fs::read_to_string(&synced_file).unwrap(), "deep content");
}
#[test]
fn test_sync_stats_returned() {
let temp_dir = TempDir::new().unwrap();
init_git_repo(temp_dir.path());
fs::write(temp_dir.path().join("untracked1.txt"), "content").unwrap();
fs::write(temp_dir.path().join("untracked2.txt"), "content").unwrap();
fs::write(temp_dir.path().join("README.md"), "# Modified").unwrap();
let config = WorktreeConfig::default();
let worktree_path = temp_dir.path().join(".worktrees/stats-test");
fs::create_dir_all(&worktree_path).unwrap();
let stats =
sync_working_directory_to_worktree(temp_dir.path(), &worktree_path, &config).unwrap();
assert_eq!(stats.untracked_copied, 2);
assert_eq!(stats.modified_copied, 1);
assert_eq!(stats.errors, 0);
}
}