use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn setup_test_repo() -> TempDir {
let dir = TempDir::new().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.expect("Failed to init git repo");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir.path())
.output()
.expect("Failed to set git user name");
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir.path())
.output()
.expect("Failed to set git user email");
fs::write(dir.path().join("README.md"), "# Test Repo").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(dir.path())
.output()
.expect("Failed to add files");
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(dir.path())
.output()
.expect("Failed to commit");
dir
}
fn get_twin_binary() -> String {
let exe_path = std::env::current_exe().unwrap();
let target_dir = exe_path.parent().unwrap().parent().unwrap();
let twin_path = target_dir.join("twin");
twin_path.to_string_lossy().to_string()
}
fn unique_worktree_path(name: &str) -> String {
let id = uuid::Uuid::new_v4().to_string()[0..8].to_string();
format!("wt-{name}-{id}")
}
#[test]
fn test_add_command_basic() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("add");
let output = Command::new(&twin)
.args(["add", &worktree_path, "-b", "test-branch"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
eprintln!("STDOUT: {stdout}");
eprintln!("STDERR: {stderr}");
assert!(
output.status.success(),
"twin add should succeed. stderr: {stderr}"
);
let list_output = Command::new("git")
.args(["worktree", "list"])
.current_dir(repo.path())
.output()
.expect("Failed to list worktrees");
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
assert!(list_stdout.contains(&worktree_path));
assert!(list_stdout.contains("test-branch"));
}
#[test]
fn test_add_without_branch_option() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("nobranch");
let output = Command::new(&twin)
.args(["add", &worktree_path])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert!(
output.status.success(),
"twin add without branch should succeed like git worktree: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_force_branch_option() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path1 = unique_worktree_path("force1");
let worktree_path2 = unique_worktree_path("force2");
Command::new("git")
.args(["branch", "existing-branch"])
.current_dir(repo.path())
.output()
.expect("Failed to create branch");
Command::new(&twin)
.args(["add", &worktree_path1, "-b", "existing-branch"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
let output = Command::new(&twin)
.args(["add", &worktree_path2, "-B", "existing-branch"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert!(output.status.success(), "twin add with -B should succeed");
}
#[test]
fn test_detach_option() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("detached");
let head_output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.expect("Failed to get HEAD");
let _head_commit = String::from_utf8_lossy(&head_output.stdout)
.trim()
.to_string();
let output = Command::new(&twin)
.args(["add", &worktree_path, "--detach"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert!(
output.status.success(),
"Detach should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let list_output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(repo.path())
.output()
.expect("Failed to list worktrees");
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
assert!(list_stdout.contains("detached"));
}
#[test]
fn test_lock_option() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("locked");
let output = Command::new(&twin)
.args(["add", &worktree_path, "-b", "locked-branch", "--lock"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert!(output.status.success());
let list_output = Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(repo.path())
.output()
.expect("Failed to list worktrees");
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
assert!(list_stdout.contains("locked"));
}
#[test]
fn test_no_checkout_option() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("nocheckout");
let output = Command::new(&twin)
.args(["add", &worktree_path, "-b", "empty-branch", "--no-checkout"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert!(
output.status.success(),
"No-checkout should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let worktree_full_path = repo.path().join(&worktree_path);
let entries: Vec<_> = fs::read_dir(&worktree_full_path)
.expect("Failed to read worktree dir")
.collect();
assert!(entries.len() <= 1, "Worktree should be nearly empty");
}
#[test]
fn test_quiet_option() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("quiet");
let output = Command::new(&twin)
.args(["add", &worktree_path, "-b", "quiet-branch", "--quiet"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.is_empty() || stdout.trim().is_empty(),
"Quiet mode should suppress output"
);
}
#[test]
fn test_git_only_mode() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("gitonly");
let config = r#"
[[files]]
path = "test.txt"
mapping_type = "symlink"
"#;
fs::write(repo.path().join(".twin.toml"), config).unwrap();
fs::write(repo.path().join("test.txt"), "test content").unwrap();
let output = Command::new(&twin)
.args([
"add",
&worktree_path,
"-b",
"git-only-branch",
"--config",
".twin.toml",
"--git-only",
])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert!(output.status.success());
let worktree_full_path = repo.path().join(&worktree_path);
assert!(!worktree_full_path.join("test.txt").exists());
}
#[test]
fn test_error_message_passthrough() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("error");
Command::new(&twin)
.args(["add", &worktree_path, "-b", "test-branch"])
.current_dir(repo.path())
.output()
.expect("Failed to execute first twin add");
let output = Command::new(&twin)
.args(["add", &worktree_path, "-b", "another-branch"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Error") || stderr.contains("fatal") || stderr.contains("already exists")
);
}
#[test]
fn test_invalid_branch_name_error() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("invalid");
let output = Command::new(&twin)
.args(["add", &worktree_path, "-b", "invalid..branch"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("invalid") || stderr.contains("fatal"),
"Should show branch name error"
);
}
#[test]
fn test_list_includes_manual_worktree() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("manual");
Command::new("git")
.args(["worktree", "add", &worktree_path, "-b", "manual-branch"])
.current_dir(repo.path())
.output()
.expect("Failed to create manual worktree");
let output = Command::new(&twin)
.args(["list"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin list");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("manual-branch"));
}
#[test]
fn test_remove_manual_worktree() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path = unique_worktree_path("remove");
Command::new("git")
.args(["worktree", "add", &worktree_path, "-b", "to-remove"])
.current_dir(repo.path())
.output()
.expect("Failed to create manual worktree");
let output = Command::new(&twin)
.args(["remove", &worktree_path, "--force"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin remove");
assert!(output.status.success());
let list_output = Command::new("git")
.args(["worktree", "list"])
.current_dir(repo.path())
.output()
.expect("Failed to list worktrees");
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
assert!(!list_stdout.contains("to-remove"));
}
#[test]
fn test_output_matches_git_worktree() {
let repo = setup_test_repo();
let twin = get_twin_binary();
let worktree_path1 = unique_worktree_path("git1");
let worktree_path2 = unique_worktree_path("twin1");
let git_output = Command::new("git")
.args(["worktree", "add", &worktree_path1, "-b", "git-branch"])
.current_dir(repo.path())
.output()
.expect("Failed to execute git worktree add");
let twin_output = Command::new(&twin)
.args(["add", &worktree_path2, "-b", "twin-branch"])
.current_dir(repo.path())
.output()
.expect("Failed to execute twin add");
assert_eq!(git_output.status.success(), twin_output.status.success());
let list_output = Command::new("git")
.args(["worktree", "list"])
.current_dir(repo.path())
.output()
.expect("Failed to list worktrees");
let list_stdout = String::from_utf8_lossy(&list_output.stdout);
assert!(list_stdout.contains("git-branch"));
assert!(list_stdout.contains("twin-branch"));
}