use crate::common::{TestRepo, make_snapshot_cmd, make_snapshot_cmd_with_global_flags, repo};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use std::path::{Path, PathBuf};
const CUSTOM_COPY_IGNORED_EXCLUDE_CONFIG: &str = r#"[step.copy-ignored]
exclude = [".custom-cache/"]
"#;
fn run_copy_ignored(repo: &TestRepo, feature_path: &Path) -> std::process::Output {
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(feature_path)
.output()
.unwrap();
assert!(
output.status.success(),
"copy-ignored should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
output
}
fn run_copy_ignored_single_entry(repo: &TestRepo, feature_path: &Path) {
let output = run_copy_ignored(repo, feature_path);
assert!(
String::from_utf8_lossy(&output.stderr).contains("Copied 1 file"),
"expected one copied file: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn write_worktree_project_config(worktree_path: &Path, contents: &str) {
let config_dir = worktree_path.join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), contents).unwrap();
}
fn assert_copy_ignored_excluded(feature_path: &Path, excluded_dirs: &[&str], source: &str) {
assert!(
feature_path.join(".env").exists(),
".env should still be copied"
);
for excluded_dir in excluded_dirs {
assert!(
!feature_path.join(excluded_dir).exists(),
"{} should be excluded by {}",
excluded_dir,
source
);
}
}
fn setup_copy_ignored_exclude_fixture(repo: &mut TestRepo) -> PathBuf {
let feature_path = repo.add_worktree("feature");
let ignored_entries = ".env\n.custom-cache/\n";
fs::create_dir_all(repo.root_path().join(".custom-cache")).unwrap();
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(
repo.root_path().join(".custom-cache").join("state.json"),
"{}",
)
.unwrap();
fs::write(repo.root_path().join(".gitignore"), ignored_entries).unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ignored_entries).unwrap();
feature_path
}
#[rstest]
fn test_copy_ignored_no_worktreeinclude(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored"],
Some(&feature_path),
));
}
#[rstest]
fn test_copy_ignored_default_copies_all(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join("cache.db"), "cached data").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\ncache.db\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored"],
Some(&feature_path),
));
assert!(
feature_path.join(".env").exists(),
".env should be copied without .worktreeinclude"
);
assert!(
feature_path.join("cache.db").exists(),
"cache.db should be copied without .worktreeinclude"
);
}
#[rstest]
fn test_copy_ignored_excludes_project_config(mut repo: TestRepo) {
let feature_path = setup_copy_ignored_exclude_fixture(&mut repo);
repo.write_project_config(CUSTOM_COPY_IGNORED_EXCLUDE_CONFIG);
write_worktree_project_config(&feature_path, CUSTOM_COPY_IGNORED_EXCLUDE_CONFIG);
run_copy_ignored_single_entry(&repo, &feature_path);
assert_copy_ignored_excluded(&feature_path, &[".custom-cache"], "project config");
}
#[rstest]
fn test_copy_ignored_excludes_user_config(mut repo: TestRepo) {
let feature_path = setup_copy_ignored_exclude_fixture(&mut repo);
repo.write_test_config(CUSTOM_COPY_IGNORED_EXCLUDE_CONFIG);
run_copy_ignored_single_entry(&repo, &feature_path);
assert_copy_ignored_excluded(&feature_path, &[".custom-cache"], "user config");
}
#[rstest]
fn test_copy_ignored_skips_built_in_excluded_dirs(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let ignored_entries = ".conductor/\n.entire/\n.pi/\n.env\n";
for dir in [".conductor", ".entire", ".pi"] {
fs::create_dir_all(repo.root_path().join(dir)).unwrap();
fs::write(repo.root_path().join(dir).join("state.json"), dir).unwrap();
}
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ignored_entries).unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ignored_entries).unwrap();
run_copy_ignored_single_entry(&repo, &feature_path);
assert_copy_ignored_excluded(
&feature_path,
&[".conductor", ".entire", ".pi"],
"built-in excludes",
);
}
#[rstest]
fn test_copy_ignored_invalid_worktreeinclude(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".worktreeinclude"), "{unclosed\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored"],
Some(&feature_path),
));
}
#[rstest]
fn test_copy_ignored_empty_intersection(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored"],
Some(&feature_path),
));
}
#[rstest]
fn test_copy_ignored_not_ignored_file(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored"],
Some(&feature_path),
));
}
#[rstest]
fn test_copy_ignored_basic(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
run_copy_ignored(&repo, &feature_path);
let copied_env = feature_path.join(".env");
assert!(
copied_env.exists(),
".env should be copied to feature worktree"
);
assert_eq!(
fs::read_to_string(&copied_env).unwrap(),
"SECRET=value",
".env content should match"
);
}
#[rstest]
fn test_copy_ignored_idempotent(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
run_copy_ignored(&repo, &feature_path);
run_copy_ignored(&repo, &feature_path);
assert_eq!(
fs::read_to_string(feature_path.join(".env")).unwrap(),
"SECRET=value"
);
}
#[rstest]
fn test_copy_ignored_nested_file(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let cache_dir = repo.root_path().join("cache");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("data.json"), r#"{"key": "value"}"#).unwrap();
fs::write(repo.root_path().join(".gitignore"), "cache/data.json\n").unwrap();
fs::write(
repo.root_path().join(".worktreeinclude"),
"cache/data.json\n",
)
.unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(output.status.success());
let copied_file = feature_path.join("cache").join("data.json");
assert!(copied_file.exists(), "Nested file should be copied");
assert_eq!(
fs::read_to_string(&copied_file).unwrap(),
r#"{"key": "value"}"#
);
}
#[rstest]
fn test_copy_ignored_dry_run(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored", "--dry-run"],
Some(&feature_path),
));
let copied_env = feature_path.join(".env");
assert!(
!copied_env.exists(),
".env should NOT be copied in dry-run mode"
);
}
#[rstest]
fn test_copy_ignored_directory(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let target_dir = repo.root_path().join("target");
fs::create_dir_all(target_dir.join("debug")).unwrap();
fs::write(target_dir.join("debug").join("output"), "binary content").unwrap();
fs::write(target_dir.join("CACHEDIR.TAG"), "cache tag").unwrap();
fs::write(repo.root_path().join(".gitignore"), "target/\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), "target/\n").unwrap();
run_copy_ignored(&repo, &feature_path);
let copied_target = feature_path.join("target");
assert!(copied_target.exists(), "target should be copied");
assert!(
copied_target.join("debug").join("output").exists(),
"target/debug/output should be copied"
);
assert_eq!(
fs::read_to_string(copied_target.join("debug").join("output")).unwrap(),
"binary content"
);
}
#[rstest]
fn test_copy_ignored_glob_pattern(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "base").unwrap();
fs::write(repo.root_path().join(".env.local"), "local").unwrap();
fs::write(repo.root_path().join(".env.test"), "test").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env*\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env*\n").unwrap();
run_copy_ignored(&repo, &feature_path);
assert!(feature_path.join(".env").exists());
assert!(feature_path.join(".env.local").exists());
assert!(feature_path.join(".env.test").exists());
}
#[rstest]
fn test_copy_ignored_same_worktree(repo: TestRepo) {
fs::write(repo.root_path().join(".env"), "value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "step", &["copy-ignored"], None,));
}
#[rstest]
fn test_copy_ignored_from_flag(mut repo: TestRepo) {
let feature_a = repo.add_worktree("feature-a");
let feature_b = repo.add_worktree("feature-b");
fs::write(feature_a.join(".env"), "from-feature-a").unwrap();
fs::write(feature_a.join(".gitignore"), ".env\n").unwrap();
fs::write(feature_a.join(".worktreeinclude"), ".env\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored", "--from", "feature-a"],
Some(&feature_b),
));
assert!(feature_b.join(".env").exists());
assert_eq!(
fs::read_to_string(feature_b.join(".env")).unwrap(),
"from-feature-a"
);
}
#[rstest]
fn test_copy_ignored_cow_independence(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "original").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
repo.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.expect("copy-ignored should succeed");
fs::write(feature_path.join(".env"), "modified").unwrap();
assert_eq!(
fs::read_to_string(repo.root_path().join(".env")).unwrap(),
"original",
"Original file should be unchanged after modifying copy"
);
}
#[rstest]
fn test_copy_ignored_deep_pattern(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let claude_dir = repo.root_path().join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("settings.local.json"), r#"{"key":"value"}"#).unwrap();
fs::write(
repo.root_path().join(".gitignore"),
"**/.claude/settings.local.json\n",
)
.unwrap();
fs::write(
repo.root_path().join(".worktreeinclude"),
"**/.claude/settings.local.json\n",
)
.unwrap();
run_copy_ignored(&repo, &feature_path);
assert!(
feature_path
.join(".claude")
.join("settings.local.json")
.exists()
);
}
#[rstest]
fn test_copy_ignored_nested_gitignore(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let subdir = repo.root_path().join("config");
fs::create_dir_all(&subdir).unwrap();
fs::write(subdir.join("local.json"), r#"{"local":true}"#).unwrap();
fs::write(subdir.join(".gitignore"), "local.json\n").unwrap();
fs::write(
repo.root_path().join(".worktreeinclude"),
"config/local.json\n",
)
.unwrap();
run_copy_ignored(&repo, &feature_path);
assert!(
feature_path.join("config").join("local.json").exists(),
"File ignored by nested .gitignore should be copied"
);
}
#[rstest]
fn test_copy_ignored_to_flag(mut repo: TestRepo) {
let feature_a = repo.add_worktree("feature-a");
let feature_b = repo.add_worktree("feature-b");
fs::write(repo.root_path().join(".env"), "from-main").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored", "--to", "feature-b"],
Some(&feature_a),
));
assert!(feature_b.join(".env").exists());
assert!(!feature_a.join(".env").exists());
assert_eq!(
fs::read_to_string(feature_b.join(".env")).unwrap(),
"from-main"
);
}
#[rstest]
fn test_copy_ignored_from_nonexistent_worktree(repo: TestRepo) {
repo.git_command()
.args(["branch", "orphan-branch"])
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored", "--from", "orphan-branch"],
None,
));
}
#[rstest]
fn test_copy_ignored_to_nonexistent_worktree(repo: TestRepo) {
repo.git_command()
.args(["branch", "orphan-branch"])
.run()
.unwrap();
fs::write(repo.root_path().join(".env"), "value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored", "--to", "orphan-branch"],
None,
));
}
#[rstest]
fn test_copy_ignored_no_default_branch_worktree(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
repo.switch_primary_to("develop");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
run_copy_ignored(&repo, &feature_path);
assert!(
feature_path.join(".env").exists(),
".env should be copied from main worktree"
);
}
#[test]
fn test_copy_ignored_bare_repo() {
use crate::common::{BareRepoTest, TestRepoBase, setup_temp_snapshot_settings, wt_command};
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit on main");
let feature_worktree = test.create_worktree("feature", "feature");
test.commit_in(&feature_worktree, "Feature work");
fs::write(main_worktree.join(".env"), "SECRET=value").unwrap();
fs::write(main_worktree.join(".gitignore"), ".env\n").unwrap();
fs::write(main_worktree.join(".worktreeinclude"), ".env\n").unwrap();
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
cmd.args(["step", "copy-ignored"])
.current_dir(&feature_worktree);
insta_cmd::assert_cmd_snapshot!(cmd);
});
assert!(
feature_worktree.join(".env").exists(),
".env should be copied to feature worktree"
);
}
#[rstest]
fn test_copy_ignored_force_overwrites(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "NEW_SECRET=updated").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
fs::write(feature_path.join(".env"), "OLD_SECRET=stale").unwrap();
repo.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert_eq!(
fs::read_to_string(feature_path.join(".env")).unwrap(),
"OLD_SECRET=stale",
"Without --force, existing file should not be overwritten"
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored", "--force"],
Some(&feature_path),
));
assert_eq!(
fs::read_to_string(feature_path.join(".env")).unwrap(),
"NEW_SECRET=updated",
"With --force, existing file should be overwritten"
);
}
#[rstest]
fn test_copy_ignored_force_no_existing(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), ".env\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored", "--force"],
Some(&feature_path),
));
assert_eq!(
fs::read_to_string(feature_path.join(".env")).unwrap(),
"SECRET=value",
"With --force, file should be copied even when dest doesn't exist"
);
}
#[rstest]
fn test_copy_ignored_force_directory(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let target_dir = repo.root_path().join("target");
fs::create_dir_all(target_dir.join("debug")).unwrap();
fs::write(target_dir.join("debug").join("output"), "new content").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("output", target_dir.join("debug").join("link")).unwrap();
fs::write(repo.root_path().join(".gitignore"), "target/\n").unwrap();
fs::write(repo.root_path().join(".worktreeinclude"), "target/\n").unwrap();
fs::create_dir_all(feature_path.join("target").join("debug")).unwrap();
fs::write(
feature_path.join("target").join("debug").join("output"),
"old content",
)
.unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(
"old_target",
feature_path.join("target").join("debug").join("link"),
)
.unwrap();
repo.wt_command()
.args(["step", "copy-ignored", "--force"])
.current_dir(&feature_path)
.output()
.unwrap();
assert_eq!(
fs::read_to_string(feature_path.join("target").join("debug").join("output")).unwrap(),
"new content",
"With --force, files inside directories should be overwritten"
);
#[cfg(unix)]
assert_eq!(
fs::read_link(feature_path.join("target").join("debug").join("link")).unwrap(),
std::path::PathBuf::from("output"),
"With --force, symlinks inside directories should be overwritten"
);
}
#[rstest]
fn test_copy_ignored_verbose(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
&repo,
"step",
&["copy-ignored"],
Some(&feature_path),
&["-v"],
));
assert!(feature_path.join(".env").exists());
}
#[rstest]
fn test_copy_ignored_verbose_directory(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let target_dir = repo.root_path().join("target");
fs::create_dir_all(target_dir.join("debug")).unwrap();
fs::write(target_dir.join("debug").join("output"), "binary").unwrap();
fs::write(repo.root_path().join(".gitignore"), "target/\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd_with_global_flags(
&repo,
"step",
&["copy-ignored"],
Some(&feature_path),
&["-v"],
));
assert!(
feature_path
.join("target")
.join("debug")
.join("output")
.exists()
);
}
#[rstest]
fn test_copy_ignored_counts_files_not_entries(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let target_dir = repo.root_path().join("target");
fs::create_dir_all(target_dir.join("debug/deps")).unwrap();
fs::write(target_dir.join("debug/output"), "bin1").unwrap();
fs::write(target_dir.join("debug/deps/libfoo.rlib"), "lib").unwrap();
fs::write(target_dir.join("debug/deps/libbar.rlib"), "lib").unwrap();
fs::write(repo.root_path().join(".gitignore"), "target/\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored"],
Some(&feature_path),
));
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_broken_symlink_idempotent(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let target_dir = repo.root_path().join("target");
fs::create_dir_all(&target_dir).unwrap();
std::os::unix::fs::symlink("nonexistent", target_dir.join("link")).unwrap();
fs::write(repo.root_path().join(".gitignore"), "target/\n").unwrap();
let dest_target = feature_path.join("target");
fs::create_dir_all(&dest_target).unwrap();
std::os::unix::fs::symlink("old_target", dest_target.join("link")).unwrap();
assert!(dest_target.join("link").symlink_metadata().is_ok());
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(
output.status.success(),
"copy-ignored should succeed with broken symlink at destination: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_skips_non_regular_files(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let target_dir = repo.root_path().join("target");
fs::create_dir_all(&target_dir).unwrap();
let socket_path = target_dir.join("test.sock");
let _listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap();
fs::write(target_dir.join("data.txt"), "content").unwrap();
fs::write(repo.root_path().join(".gitignore"), "target/\n").unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(
output.status.success(),
"copy-ignored should succeed with socket in directory: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(feature_path.join("target").join("data.txt").exists());
assert!(!feature_path.join("target").join("test.sock").exists());
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_preserves_top_level_symlinks(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join("test_file"), "content").unwrap();
std::os::unix::fs::symlink("test_file", repo.root_path().join("symlink_to_test_file")).unwrap();
fs::write(
repo.root_path().join(".gitignore"),
"test_file\nsymlink_to_test_file\n",
)
.unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(output.status.success());
let dest_symlink = feature_path.join("symlink_to_test_file");
assert!(
dest_symlink
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink(),
"symlink_to_test_file should be a symlink, not a regular file"
);
assert_eq!(
fs::read_link(&dest_symlink).unwrap(),
std::path::PathBuf::from("test_file"),
"symlink target should be preserved"
);
assert!(feature_path.join("test_file").exists());
let output2 = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(output2.status.success());
assert!(
dest_symlink
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink(),
"symlink should survive idempotent re-run"
);
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_directory_with_symlinks(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let target_dir = repo.root_path().join("target");
fs::create_dir_all(&target_dir).unwrap();
fs::write(target_dir.join("data.txt"), "content").unwrap();
std::os::unix::fs::symlink("data.txt", target_dir.join("link.txt")).unwrap();
fs::write(repo.root_path().join(".gitignore"), "target/\n").unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(output.status.success());
assert!(feature_path.join("target").join("data.txt").exists());
let link = feature_path.join("target").join("link.txt");
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
assert_eq!(fs::read_link(&link).unwrap().to_str().unwrap(), "data.txt");
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_error_includes_path_directory(mut repo: TestRepo) {
use std::os::unix::fs::PermissionsExt;
let feature_path = repo.add_worktree("feature");
let target_dir = repo.root_path().join("target");
fs::create_dir_all(target_dir.join("sub")).unwrap();
fs::write(target_dir.join("sub").join("file.txt"), "content").unwrap();
fs::write(repo.root_path().join(".gitignore"), "target/\n").unwrap();
let dest_sub = feature_path.join("target").join("sub");
fs::create_dir_all(&dest_sub).unwrap();
fs::set_permissions(&dest_sub, fs::Permissions::from_mode(0o555)).unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
fs::set_permissions(&dest_sub, fs::Permissions::from_mode(0o755)).unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("copying"),
"Error should mention the file path, got: {stderr}"
);
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_error_includes_path_file(mut repo: TestRepo) {
use std::os::unix::fs::PermissionsExt;
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
fs::set_permissions(&feature_path, fs::Permissions::from_mode(0o555)).unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
fs::set_permissions(&feature_path, fs::Permissions::from_mode(0o755)).unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("copying") && stderr.contains(".env"),
"Error should mention the file path, got: {stderr}"
);
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_error_nested_file_parent_creation(mut repo: TestRepo) {
use std::os::unix::fs::PermissionsExt;
let feature_path = repo.add_worktree("feature");
let cache_dir = repo.root_path().join("cache");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("data.json"), "content").unwrap();
fs::write(repo.root_path().join(".gitignore"), "cache/data.json\n").unwrap();
fs::write(
repo.root_path().join(".worktreeinclude"),
"cache/data.json\n",
)
.unwrap();
fs::set_permissions(&feature_path, fs::Permissions::from_mode(0o555)).unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
fs::set_permissions(&feature_path, fs::Permissions::from_mode(0o755)).unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("creating directory for") && stderr.contains("cache"),
"Error should mention parent directory creation, got: {stderr}"
);
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_broken_symlink_at_dest_for_regular_file(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap();
std::os::unix::fs::symlink("/nonexistent/path/file", feature_path.join(".env")).unwrap();
assert!(feature_path.join(".env").symlink_metadata().is_ok());
assert!(!feature_path.join(".env").exists());
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(
output.status.success(),
"copy-ignored should succeed when destination has broken symlink: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_broken_symlink_in_dir_at_dest(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let target_dir = repo.root_path().join("target");
fs::create_dir_all(&target_dir).unwrap();
fs::write(target_dir.join("data.txt"), "content").unwrap();
fs::write(repo.root_path().join(".gitignore"), "target/\n").unwrap();
let dest_target = feature_path.join("target");
fs::create_dir_all(&dest_target).unwrap();
std::os::unix::fs::symlink("/nonexistent/path/file", dest_target.join("data.txt")).unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(
output.status.success(),
"copy-ignored should succeed when dir entry has broken symlink at dest: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_preserves_directory_permissions(mut repo: TestRepo) {
use std::os::unix::fs::PermissionsExt;
let feature_path = repo.add_worktree("feature");
let test_dir = repo.root_path().join("test");
fs::create_dir_all(&test_dir).unwrap();
fs::write(test_dir.join("file"), "content").unwrap();
fs::set_permissions(&test_dir, fs::Permissions::from_mode(0o700)).unwrap();
fs::write(repo.root_path().join(".gitignore"), "test\n").unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(
output.status.success(),
"copy-ignored should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let dest_dir = feature_path.join("test");
assert!(dest_dir.exists(), "test directory should be copied");
assert!(dest_dir.join("file").exists(), "test/file should be copied");
let dest_mode = fs::metadata(&dest_dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
dest_mode, 0o700,
"Directory permissions should be preserved (expected 0700, got {dest_mode:04o})"
);
let readonly_dir = repo.root_path().join("readonly");
fs::create_dir_all(&readonly_dir).unwrap();
fs::write(readonly_dir.join("data"), "content").unwrap();
fs::set_permissions(&readonly_dir, fs::Permissions::from_mode(0o555)).unwrap();
fs::write(repo.root_path().join(".gitignore"), "test\nreadonly\n").unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
let dest_readonly = feature_path.join("readonly");
assert!(
output.status.success(),
"copy-ignored should handle read-only source dirs: {}",
String::from_utf8_lossy(&output.stderr)
);
let dest_readonly_mode = fs::metadata(&dest_readonly).unwrap().permissions().mode() & 0o777;
fs::set_permissions(&readonly_dir, fs::Permissions::from_mode(0o755)).unwrap();
if dest_readonly.exists() {
fs::set_permissions(&dest_readonly, fs::Permissions::from_mode(0o755)).unwrap();
}
assert_eq!(
dest_readonly_mode, 0o555,
"Read-only directory permissions should be preserved (expected 0555, got {dest_readonly_mode:04o})"
);
}
#[cfg(unix)]
#[rstest]
fn test_copy_ignored_preserves_file_executable_permissions(mut repo: TestRepo) {
use std::os::unix::fs::PermissionsExt;
let feature_path = repo.add_worktree("feature");
let bin_dir = repo.root_path().join("node_modules/.bin");
fs::create_dir_all(&bin_dir).unwrap();
fs::write(bin_dir.join("playwright"), "#!/bin/sh\necho playwright").unwrap();
fs::set_permissions(
bin_dir.join("playwright"),
fs::Permissions::from_mode(0o755),
)
.unwrap();
fs::write(bin_dir.join("config.json"), r#"{"key": "value"}"#).unwrap();
fs::write(
repo.root_path().join("run-tests.sh"),
"#!/bin/sh\necho running tests",
)
.unwrap();
fs::set_permissions(
repo.root_path().join("run-tests.sh"),
fs::Permissions::from_mode(0o755),
)
.unwrap();
fs::write(
repo.root_path().join(".gitignore"),
"node_modules\nrun-tests.sh\n",
)
.unwrap();
let output = repo
.wt_command()
.args(["step", "copy-ignored"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(
output.status.success(),
"copy-ignored should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let dest_exec = feature_path.join("node_modules/.bin/playwright");
assert!(dest_exec.exists(), "executable file should be copied");
let dest_mode = fs::metadata(&dest_exec).unwrap().permissions().mode() & 0o777;
assert_eq!(
dest_mode, 0o755,
"Executable file permissions should be preserved (expected 0755, got {dest_mode:04o})"
);
let dest_config = feature_path.join("node_modules/.bin/config.json");
assert!(dest_config.exists(), "non-executable file should be copied");
let config_mode = fs::metadata(&dest_config).unwrap().permissions().mode() & 0o777;
assert_eq!(
config_mode, 0o644,
"Non-executable file permissions should be preserved (expected 0644, got {config_mode:04o})"
);
let dest_script = feature_path.join("run-tests.sh");
assert!(
dest_script.exists(),
"top-level executable should be copied"
);
let script_mode = fs::metadata(&dest_script).unwrap().permissions().mode() & 0o777;
assert_eq!(
script_mode, 0o755,
"Top-level executable permissions should be preserved (expected 0755, got {script_mode:04o})"
);
}
#[rstest]
fn test_copy_ignored_skips_vcs_metadata_dirs(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let jj_dir = repo.root_path().join(".jj");
fs::create_dir_all(jj_dir.join("repo")).unwrap();
fs::write(jj_dir.join("repo/store"), "jj internal state").unwrap();
let hg_dir = repo.root_path().join(".hg");
fs::create_dir_all(&hg_dir).unwrap();
fs::write(hg_dir.join("dirstate"), "hg internal state").unwrap();
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".jj/\n.hg/\n.env\n").unwrap();
run_copy_ignored(&repo, &feature_path);
assert!(
feature_path.join(".env").exists(),
".env should be copied to destination"
);
assert!(
!feature_path.join(".jj").exists(),
".jj directory should NOT be copied (VCS metadata)"
);
assert!(
!feature_path.join(".hg").exists(),
".hg directory should NOT be copied (VCS metadata)"
);
}
#[rstest]
fn test_copy_ignored_skips_nested_worktrees(mut repo: TestRepo) {
let nested_worktrees_dir = repo.root_path().join(".worktrees");
fs::create_dir_all(&nested_worktrees_dir).unwrap();
let nested_worktree_path = nested_worktrees_dir.join("feature-nested");
repo.git_command()
.args([
"worktree",
"add",
"-b",
"feature-nested",
nested_worktree_path.to_str().unwrap(),
])
.run()
.unwrap();
fs::write(repo.root_path().join(".gitignore"), ".worktrees/\n").unwrap();
fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap();
fs::write(repo.root_path().join(".gitignore"), ".worktrees/\n.env\n").unwrap();
let dest_path = repo.add_worktree("destination");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["copy-ignored"],
Some(&dest_path),
));
assert!(
dest_path.join(".env").exists(),
".env should be copied to destination"
);
assert!(
!dest_path.join(".worktrees").exists(),
".worktrees directory should NOT be copied (contains nested worktree)"
);
}
#[rstest]
fn test_copy_ignored_many_directories_no_emfile(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let mut gitignore_entries = String::new();
let mut worktreeinclude_entries = String::new();
for i in 0..200 {
let dir_name = format!("ignored-dir-{i}/");
let dir = repo.root_path().join(format!("ignored-dir-{i}"));
fs::create_dir_all(&dir).unwrap();
for j in 0..10 {
fs::write(
dir.join(format!("file-{j}.txt")),
format!("content {i}-{j}"),
)
.unwrap();
}
gitignore_entries.push_str(&dir_name);
gitignore_entries.push('\n');
worktreeinclude_entries.push_str(&dir_name);
worktreeinclude_entries.push('\n');
}
fs::write(repo.root_path().join(".gitignore"), &gitignore_entries).unwrap();
fs::write(
repo.root_path().join(".worktreeinclude"),
&worktreeinclude_entries,
)
.unwrap();
run_copy_ignored(&repo, &feature_path);
for i in [0, 99, 199] {
for j in [0, 5, 9] {
let dst_file = feature_path.join(format!("ignored-dir-{i}/file-{j}.txt"));
assert!(
dst_file.exists(),
"ignored-dir-{i}/file-{j}.txt should be copied"
);
assert_eq!(
fs::read_to_string(&dst_file).unwrap(),
format!("content {i}-{j}"),
"ignored-dir-{i}/file-{j}.txt content should match"
);
}
}
}