use crate::common::{
BareRepoTest, TestRepo, TestRepoBase, canonicalize, configure_directive_files,
configure_git_cmd, configure_git_env, directive_files, repo, setup_temp_snapshot_settings,
wait_for_file, wait_for_file_content, wait_for_worktree_removed, wt_command,
};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::Duration;
use worktrunk::shell_exec::Cmd;
#[test]
fn test_bare_repo_list_worktrees() {
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, "Work on feature");
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
cmd.arg("list").current_dir(&main_worktree);
assert_cmd_snapshot!(cmd);
});
}
#[test]
fn test_bare_repo_list_shows_no_bare_entry() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
cmd.arg("list").current_dir(&main_worktree);
assert_cmd_snapshot!(cmd);
});
}
#[test]
fn test_bare_repo_switch_creates_worktree() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature"])
.current_dir(&main_worktree);
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt switch failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let expected_path = test.bare_repo_path().join("feature");
assert!(
expected_path.exists(),
"Expected worktree at {:?}",
expected_path
);
let output = test
.git_command(test.bare_repo_path())
.args(["worktree", "list"])
.run()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.lines().count(), 3);
}
#[test]
fn test_bare_repo_switch_with_configured_naming() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature"])
.current_dir(&main_worktree);
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt switch failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let expected_path = test.bare_repo_path().join("feature");
assert!(
expected_path.exists(),
"Expected worktree at {:?}",
expected_path
);
}
#[test]
fn test_bare_repo_remove_worktree() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let feature_worktree = test.create_worktree("feature", "feature");
test.commit_in(&feature_worktree, "Feature work");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["remove", "feature", "--foreground"])
.current_dir(&main_worktree);
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt remove failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
assert!(
!feature_worktree.exists(),
"Feature worktree should be removed"
);
assert!(main_worktree.exists());
}
#[test]
fn test_bare_repo_identifies_primary_correctly() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Main commit");
let _feature1 = test.create_worktree("feature1", "feature1");
let _feature2 = test.create_worktree("feature2", "feature2");
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
cmd.arg("list").current_dir(&main_worktree);
assert_cmd_snapshot!(cmd);
});
}
#[test]
fn test_bare_repo_path_used_for_worktree_paths() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "dev"])
.current_dir(&main_worktree);
cmd.output().unwrap();
let expected = test.bare_repo_path().join("dev");
assert!(
expected.exists(),
"Worktree should be created using repo_path: {:?}",
expected
);
let wrong_path = main_worktree.parent().unwrap().join("main.dev");
assert!(
!wrong_path.exists(),
"Worktree should not use worktree directory as base"
);
}
#[test]
fn test_bare_repo_with_repo_path_variable() {
let test = BareRepoTest::new();
fs::write(
test.config_path(),
"worktree-path = \"{{ repo_path }}/../worktrees/{{ branch | sanitize }}\"\n",
)
.unwrap();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature/auth"])
.current_dir(&main_worktree);
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt switch failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let expected_path = test
.bare_repo_path()
.parent()
.unwrap()
.join("worktrees")
.join("feature-auth");
assert!(
expected_path.exists(),
"Expected worktree at {:?} (using repo_path variable)",
expected_path
);
}
#[test]
fn test_bare_repo_repo_path_with_inherited_relative_git_dir() {
let test = BareRepoTest::new();
let user_config = test.temp_path().join("user-config.toml");
fs::write(
&user_config,
"[aliases]\nprint-repo-path = \"echo REPO_PATH={{ repo_path }}\"\n",
)
.unwrap();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let extract_repo_path = |out: &std::process::Output| -> String {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
combined
.lines()
.find_map(|line| line.trim().strip_prefix("REPO_PATH=").map(str::to_owned))
.unwrap_or_else(|| panic!("no REPO_PATH= line in output:\n{combined}"))
};
let mut baseline = wt_command();
test.configure_wt_cmd(&mut baseline);
baseline
.env("WORKTRUNK_CONFIG_PATH", &user_config)
.args(["step", "print-repo-path"])
.current_dir(&main_worktree);
let baseline_out = baseline.output().unwrap();
assert!(
baseline_out.status.success(),
"baseline wt failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&baseline_out.stdout),
String::from_utf8_lossy(&baseline_out.stderr)
);
let baseline_repo_path = extract_repo_path(&baseline_out);
let worktree_git_dir = test.bare_repo_path().join("worktrees").join("main");
assert!(
worktree_git_dir.exists(),
"expected linked worktree admin dir at {worktree_git_dir:?}"
);
let relative_git_dir = PathBuf::from("..").join("worktrees").join("main");
let mut via_alias = wt_command();
test.configure_wt_cmd(&mut via_alias);
via_alias
.env("WORKTRUNK_CONFIG_PATH", &user_config)
.env("GIT_DIR", &relative_git_dir)
.env("GIT_PREFIX", "")
.args(["step", "print-repo-path"])
.current_dir(&main_worktree);
let via_alias_out = via_alias.output().unwrap();
assert!(
via_alias_out.status.success(),
"wt via git alias failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&via_alias_out.stdout),
String::from_utf8_lossy(&via_alias_out.stderr)
);
let via_alias_repo_path = extract_repo_path(&via_alias_out);
assert_eq!(
baseline_repo_path, via_alias_repo_path,
"repo_path differed when invoked via a simulated git alias \
(relative GIT_DIR was not normalized — see #1914)"
);
}
#[cfg(not(windows))]
#[test]
fn test_repo_path_via_real_git_alias_bare_dot_git_layout() {
use crate::common::{configure_git_env, wt_bin};
let temp_dir = tempfile::TempDir::new().unwrap();
let temp_path = canonicalize(temp_dir.path()).unwrap();
let git_config_path = temp_path.join("test-gitconfig");
fs::write(
&git_config_path,
"[user]\n\tname = Test User\n\temail = test@example.com\n\
[init]\n\tdefaultBranch = main\n",
)
.unwrap();
let repo_dir = temp_path.join("repo");
fs::create_dir(&repo_dir).unwrap();
let bare_git = repo_dir.join(".git");
let git = |dir: &Path| configure_git_env(Cmd::new("git"), &git_config_path).current_dir(dir);
git(&temp_path)
.args(["init", "--bare", "--initial-branch", "main"])
.arg(bare_git.to_str().unwrap())
.run()
.unwrap();
git(&bare_git)
.args(["worktree", "add", "../main"])
.run()
.unwrap();
let main_worktree = repo_dir.join("main");
fs::write(main_worktree.join("a.txt"), "hello").unwrap();
git(&main_worktree).args(["add", "a.txt"]).run().unwrap();
git(&main_worktree)
.args(["commit", "-m", "Initial commit"])
.run()
.unwrap();
let user_config = temp_path.join("test-config.toml");
fs::write(
&user_config,
"worktree-path = \"{{ branch }}\"\n\
[aliases]\nprint-repo-path = \"echo REPO_PATH={{ repo_path }}\"\n",
)
.unwrap();
let approvals_path = temp_path.join("test-approvals.toml");
let wt_path = wt_bin();
let wt_path_lossy = wt_path.to_string_lossy();
let wt_path_escaped = shell_escape::unix::escape(wt_path_lossy);
git(&bare_git)
.args(["config", "alias.wt", &format!("!{wt_path_escaped}")])
.run()
.unwrap();
let extract_repo_path = |out: &std::process::Output| -> String {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
combined
.lines()
.find_map(|line| line.trim().strip_prefix("REPO_PATH=").map(str::to_owned))
.unwrap_or_else(|| panic!("no REPO_PATH= line in output:\n{combined}"))
};
let apply_wt_env = |cmd: &mut Command| {
configure_git_cmd(cmd, &git_config_path);
cmd.env("WORKTRUNK_CONFIG_PATH", &user_config)
.env(
"WORKTRUNK_SYSTEM_CONFIG_PATH",
"/etc/xdg/worktrunk/config.toml",
)
.env("WORKTRUNK_APPROVALS_PATH", &approvals_path)
.env_remove("NO_COLOR")
.env_remove("CLICOLOR_FORCE");
};
let mut baseline = Command::new(wt_bin());
apply_wt_env(&mut baseline);
baseline
.args(["step", "print-repo-path"])
.current_dir(&repo_dir);
let baseline_out = baseline.output().unwrap();
assert!(
baseline_out.status.success(),
"baseline wt failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&baseline_out.stdout),
String::from_utf8_lossy(&baseline_out.stderr)
);
let baseline_repo_path = extract_repo_path(&baseline_out);
let mut via_alias = Command::new("git");
apply_wt_env(&mut via_alias);
via_alias
.args(["wt", "step", "print-repo-path"])
.current_dir(&repo_dir);
let via_alias_out = via_alias.output().unwrap();
assert!(
via_alias_out.status.success(),
"git wt via real alias failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&via_alias_out.stdout),
String::from_utf8_lossy(&via_alias_out.stderr)
);
let via_alias_repo_path = extract_repo_path(&via_alias_out);
assert_eq!(
baseline_repo_path, via_alias_repo_path,
"repo_path differed when invoked via a real `git wt` alias — see #1914"
);
}
#[rstest]
fn test_bare_repo_equivalent_to_normal_repo(repo: TestRepo) {
for branch in &["feature-a", "feature-b", "feature-c"] {
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("repo.{}", branch));
if worktree_path.exists() {
repo.git_command()
.args([
"worktree",
"remove",
"--force",
worktree_path.to_str().unwrap(),
])
.run()
.unwrap();
}
}
let bare_test = BareRepoTest::new();
let bare_main = bare_test.create_worktree("main", "main");
bare_test.commit_in(&bare_main, "Commit in bare repo");
repo.commit("Commit in normal repo");
let config = r#"
worktree-path = "{{ branch }}"
"#;
fs::write(bare_test.config_path(), config).unwrap();
fs::write(repo.test_config_path(), config).unwrap();
let mut bare_list = wt_command();
bare_test.configure_wt_cmd(&mut bare_list);
bare_list.arg("list").current_dir(&bare_main);
let mut normal_list = wt_command();
repo.configure_wt_cmd(&mut normal_list);
normal_list.arg("list").current_dir(repo.root_path());
let bare_output = bare_list.output().unwrap();
let normal_output = normal_list.output().unwrap();
let bare_stdout = String::from_utf8_lossy(&bare_output.stdout);
let normal_stdout = String::from_utf8_lossy(&normal_output.stdout);
assert!(bare_stdout.contains("main"));
assert!(normal_stdout.contains("main"));
assert_eq!(bare_stdout.lines().count(), normal_stdout.lines().count());
}
#[test]
fn test_bare_repo_commands_from_bare_directory() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
cmd.arg("list").current_dir(test.bare_repo_path());
assert_cmd_snapshot!(cmd);
});
}
#[test]
fn test_bare_repo_merge_workflow() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit on main");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature"])
.current_dir(&main_worktree);
cmd.output().unwrap();
let feature_worktree = test.bare_repo_path().join("feature");
assert!(feature_worktree.exists());
test.commit_in(&feature_worktree, "Feature work");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args([
"merge",
"main", "--no-squash", "--no-hooks", ])
.current_dir(&feature_worktree);
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt merge failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
wait_for_worktree_removed(&feature_worktree);
assert!(main_worktree.exists());
let log_output = test
.git_command(&main_worktree)
.args(["log", "--oneline"])
.run()
.unwrap();
let log = String::from_utf8_lossy(&log_output.stdout);
assert!(
log.contains("Feature work"),
"Main should contain feature commit after merge"
);
}
#[test]
fn test_bare_repo_background_logs_location() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let feature_worktree = test.create_worktree("feature", "feature");
test.commit_in(&feature_worktree, "Feature work");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["remove", "feature"]).current_dir(&main_worktree);
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt remove failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let log_dir = test.bare_repo_path().join("wt/logs");
let remove_log = log_dir
.join(worktrunk::path::sanitize_for_filename("feature"))
.join("internal")
.join("remove.log");
wait_for_file(&remove_log);
assert!(
remove_log.exists(),
"Expected remove log at {}",
remove_log.display()
);
let wrong_dir = main_worktree.join(".git/wt/logs");
assert!(
!wrong_dir.exists()
|| std::fs::read_dir(&wrong_dir)
.map(|d| d.count())
.unwrap_or(0)
== 0,
"Log should NOT be in worktree's .git directory"
);
}
#[test]
fn test_bare_repo_project_config_found_from_bare_root() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let config_dir = main_worktree.join(".config");
fs::create_dir_all(&config_dir).unwrap();
let marker_path = test.bare_repo_path().join("hook-ran.marker");
let marker_str = marker_path.to_str().unwrap().replace('\\', "/");
fs::write(
config_dir.join("wt.toml"),
format!("post-start = \"echo hook-executed > '{}'\"\n", marker_str),
)
.unwrap();
let output = test
.git_command(&main_worktree)
.args(["add", ".config/wt.toml"])
.run()
.unwrap();
assert!(output.status.success());
test.commit_in(&main_worktree, "Add project config");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature", "--yes"])
.current_dir(test.bare_repo_path());
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt switch failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
wait_for_file_content(&marker_path);
let content = fs::read_to_string(&marker_path).unwrap();
assert!(
content.contains("hook-executed"),
"Hook from primary worktree config should run when command is invoked from bare root. \
Marker file content: {:?}",
content
);
}
#[test]
fn test_bare_repo_project_config_found_with_dash_c_flag() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let config_dir = main_worktree.join(".config");
fs::create_dir_all(&config_dir).unwrap();
let marker_path = test.bare_repo_path().join("hook-ran-c-flag.marker");
let marker_str = marker_path.to_str().unwrap().replace('\\', "/");
fs::write(
config_dir.join("wt.toml"),
format!("post-start = \"echo hook-executed > '{}'\"\n", marker_str),
)
.unwrap();
let output = test
.git_command(&main_worktree)
.args(["add", ".config/wt.toml"])
.run()
.unwrap();
assert!(output.status.success());
test.commit_in(&main_worktree, "Add project config");
let unrelated_dir = tempfile::tempdir().unwrap();
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args([
"-C",
test.bare_repo_path().to_str().unwrap(),
"switch",
"--create",
"feature-c-flag",
"--yes",
])
.current_dir(unrelated_dir.path());
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt switch -C failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
wait_for_file_content(&marker_path);
let content = fs::read_to_string(&marker_path).unwrap();
assert!(
content.contains("hook-executed"),
"Hook from primary worktree config should run when using -C flag. \
Marker file content: {:?}",
content
);
}
#[test]
fn test_bare_repo_ignores_config_in_bare_root() {
let test = BareRepoTest::new();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let config_dir = test.bare_repo_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
let marker_path = test.bare_repo_path().join("hook-should-not-run.marker");
let marker_str = marker_path.to_str().unwrap().replace('\\', "/");
fs::write(
config_dir.join("wt.toml"),
format!("post-start = \"echo bad > '{}'\"\n", marker_str),
)
.unwrap();
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature", "--yes"])
.current_dir(test.bare_repo_path());
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt switch failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
thread::sleep(Duration::from_millis(500));
assert!(
!marker_path.exists(),
"Config in bare repo root should be ignored — only primary worktree config should be used"
);
}
#[test]
fn test_bare_repo_slashed_branch_with_sanitize() {
let test = BareRepoTest::new();
fs::write(
test.config_path(),
"worktree-path = \"{{ branch | sanitize }}\"\n",
)
.unwrap();
let main_worktree = test.create_worktree("main", "main");
test.commit_in(&main_worktree, "Initial commit");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature/auth"])
.current_dir(&main_worktree);
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt switch failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let expected_path = test.bare_repo_path().join("feature-auth");
assert!(
expected_path.exists(),
"Expected worktree at {:?} (sanitized from feature/auth)",
expected_path
);
let wrong_path = test.bare_repo_path().join("feature/auth");
assert!(
!wrong_path.exists(),
"Should not create nested directory for slashed branch"
);
let branch_output = test
.git_command(&expected_path)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.run()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&branch_output.stdout).trim(),
"feature/auth",
"Git branch name should be preserved as feature/auth"
);
}
struct NestedBareRepoTest {
temp_dir: tempfile::TempDir,
project_path: PathBuf,
bare_repo_path: PathBuf,
test_config_path: PathBuf,
git_config_path: PathBuf,
}
impl NestedBareRepoTest {
fn new() -> Self {
let temp_dir = tempfile::TempDir::new().unwrap();
let project_path = temp_dir.path().join("project");
fs::create_dir(&project_path).unwrap();
let bare_repo_path = project_path.join(".git");
let test_config_path = temp_dir.path().join("test-config.toml");
let git_config_path = temp_dir.path().join("test-gitconfig");
fs::write(
&git_config_path,
"[user]\n\tname = Test User\n\temail = test@example.com\n\
[advice]\n\tmergeConflict = false\n\tresolveConflict = false\n\
[init]\n\tdefaultBranch = main\n",
)
.unwrap();
let mut test = Self {
temp_dir,
project_path,
bare_repo_path,
test_config_path,
git_config_path,
};
let output = configure_git_env(Cmd::new("git"), &test.git_config_path)
.args(["init", "--bare", "--initial-branch", "main"])
.arg(test.bare_repo_path.to_str().unwrap())
.run()
.unwrap();
if !output.status.success() {
panic!(
"Failed to init nested bare repo:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
test.project_path = canonicalize(&test.project_path).unwrap();
test.bare_repo_path = canonicalize(&test.bare_repo_path).unwrap();
fs::write(
&test.test_config_path,
"worktree-path = \"../{{ branch }}\"\n",
)
.unwrap();
test
}
fn project_path(&self) -> &PathBuf {
&self.project_path
}
fn bare_repo_path(&self) -> &PathBuf {
&self.bare_repo_path
}
fn config_path(&self) -> &Path {
&self.test_config_path
}
fn temp_path(&self) -> &Path {
self.temp_dir.path()
}
fn configure_wt_cmd(&self, cmd: &mut Command) {
self.configure_git_cmd(cmd);
cmd.env("WORKTRUNK_CONFIG_PATH", &self.test_config_path)
.env_remove("NO_COLOR")
.env_remove("CLICOLOR_FORCE");
}
#[cfg(all(unix, feature = "shell-integration-tests"))]
fn test_env_vars(&self) -> Vec<(String, String)> {
use crate::common::{NULL_DEVICE, STATIC_TEST_ENV_VARS, TEST_EPOCH};
let mut vars: Vec<(String, String)> = STATIC_TEST_ENV_VARS
.iter()
.map(|&(k, v)| (k.to_string(), v.to_string()))
.collect();
let home = self.temp_dir.path().join("home");
std::fs::create_dir_all(&home).ok();
vars.extend([
(
"GIT_CONFIG_GLOBAL".to_string(),
self.git_config_path.display().to_string(),
),
("GIT_CONFIG_SYSTEM".to_string(), NULL_DEVICE.to_string()),
(
"GIT_AUTHOR_DATE".to_string(),
"2025-01-01T00:00:00Z".to_string(),
),
(
"GIT_COMMITTER_DATE".to_string(),
"2025-01-01T00:00:00Z".to_string(),
),
("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()),
("HOME".to_string(), home.display().to_string()),
(
"XDG_CONFIG_HOME".to_string(),
home.join(".config").display().to_string(),
),
("WORKTRUNK_TEST_EPOCH".to_string(), TEST_EPOCH.to_string()),
(
"WORKTRUNK_CONFIG_PATH".to_string(),
self.test_config_path.display().to_string(),
),
(
"WORKTRUNK_SYSTEM_CONFIG_PATH".to_string(),
"/etc/xdg/worktrunk/config.toml".to_string(),
),
(
"WORKTRUNK_APPROVALS_PATH".to_string(),
self.temp_dir
.path()
.join("test-approvals.toml")
.display()
.to_string(),
),
]);
vars
}
}
impl TestRepoBase for NestedBareRepoTest {
fn git_config_path(&self) -> &Path {
&self.git_config_path
}
}
#[test]
fn test_nested_bare_repo_worktree_path() {
let test = NestedBareRepoTest::new();
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "main"])
.current_dir(test.bare_repo_path());
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt switch --create main failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let expected_path = test.project_path().join("main");
let wrong_path = test.bare_repo_path().join("main");
assert!(
expected_path.exists(),
"Expected worktree at {:?} (sibling to .git)",
expected_path
);
assert!(
!wrong_path.exists(),
"Worktree should NOT be inside .git directory at {:?}",
wrong_path
);
}
#[test]
fn test_nested_bare_repo_full_workflow() {
let test = NestedBareRepoTest::new();
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "main"])
.current_dir(test.bare_repo_path());
cmd.output().unwrap();
let main_worktree = test.project_path().join("main");
assert!(main_worktree.exists());
test.commit_in(&main_worktree, "Initial");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature"])
.current_dir(&main_worktree);
cmd.output().unwrap();
let feature_worktree = test.project_path().join("feature");
assert!(
feature_worktree.exists(),
"Feature worktree should be at project/feature"
);
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
cmd.arg("list").current_dir(&main_worktree);
let output = cmd.output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("main"), "Should list main worktree");
assert!(stdout.contains("feature"), "Should list feature worktree");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["remove", "feature", "--foreground"])
.current_dir(&main_worktree);
cmd.output().unwrap();
assert!(
!feature_worktree.exists(),
"Feature worktree should be removed"
);
assert!(main_worktree.exists());
}
#[test]
fn test_nested_bare_repo_list_snapshot() {
let test = NestedBareRepoTest::new();
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "main"])
.current_dir(test.bare_repo_path());
cmd.output().unwrap();
let main_worktree = test.project_path().join("main");
test.commit_in(&main_worktree, "Initial");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature"])
.current_dir(&main_worktree);
cmd.output().unwrap();
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
cmd.arg("list").current_dir(&main_worktree);
assert_cmd_snapshot!(cmd);
});
}
#[test]
fn test_bare_repo_bootstrap_first_worktree() {
let test = BareRepoTest::new();
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "main"])
.current_dir(test.bare_repo_path());
let output = cmd.output().unwrap();
if !output.status.success() {
panic!(
"wt switch --create main from bare repo with no worktrees failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let expected_path = test.bare_repo_path().join("main");
assert!(
expected_path.exists(),
"Expected first worktree at {:?}",
expected_path
);
let output = test
.git_command(test.bare_repo_path())
.args(["worktree", "list"])
.run()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
stdout.lines().count(),
2,
"Should have bare repo + 1 worktree"
);
assert!(stdout.contains("main"), "Should list main worktree");
}
#[test]
fn test_clone_bare_repo_list_no_status_errors() {
let temp_dir = tempfile::TempDir::new().unwrap();
let git_config_path = temp_dir.path().join("test-gitconfig");
let test_config_path = temp_dir.path().join("test-config.toml");
fs::write(
&git_config_path,
"[user]\n\tname = Test User\n\temail = test@example.com\n\
[init]\n\tdefaultBranch = main\n",
)
.unwrap();
fs::write(&test_config_path, "").unwrap();
let run_git = |dir: &Path, args: &[&str]| {
let output = configure_git_env(Cmd::new("git"), &git_config_path)
.args(args.iter().copied())
.current_dir(dir)
.run()
.unwrap();
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
};
let source = temp_dir.path().join("source");
run_git(
temp_dir.path(),
&["init", "--initial-branch", "main", source.to_str().unwrap()],
);
fs::write(source.join("file.txt"), "content").unwrap();
run_git(&source, &["add", "file.txt"]);
run_git(&source, &["commit", "-m", "Initial commit"]);
let bare_path = temp_dir.path().join("project.bare");
run_git(
temp_dir.path(),
&[
"clone",
"--bare",
source.to_str().unwrap(),
bare_path.to_str().unwrap(),
],
);
let main_wt = temp_dir.path().join("main");
let feature_wt = temp_dir.path().join("feature");
run_git(
&bare_path,
&["worktree", "add", main_wt.to_str().unwrap(), "main"],
);
run_git(&bare_path, &["branch", "feature", "main"]);
run_git(
&bare_path,
&["worktree", "add", feature_wt.to_str().unwrap(), "feature"],
);
let mut cmd = wt_command();
configure_git_cmd(&mut cmd, &git_config_path);
cmd.env("WORKTRUNK_CONFIG_PATH", &test_config_path)
.arg("list")
.current_dir(&bare_path);
let output = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"wt list should succeed.\nstderr: {stderr}"
);
assert!(
!stderr.contains("must be run in a work tree"),
"Should not get 'must be run in a work tree' error.\nstderr: {stderr}"
);
assert!(
!stderr.contains("git operations failed"),
"Should not have git operation failures.\nstderr: {stderr}"
);
}
#[test]
fn test_bare_repo_merge_preserves_default_branch_worktree() {
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");
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args([
"merge",
"feature", "--no-squash", "--no-hooks", ])
.current_dir(&main_worktree);
let output = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
main_worktree.exists(),
"Default branch worktree must not be removed.\nstderr: {stderr}"
);
assert!(
stderr.contains("primary worktree"),
"Should show primary worktree preservation message.\nstderr: {stderr}"
);
}
fn setup_unconfigured_nested_bare_repo() -> NestedBareRepoTest {
let test = NestedBareRepoTest::new();
fs::write(
test.config_path(),
"worktree-path = \"../{{ branch | sanitize }}\"\n",
)
.unwrap();
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "main", "--yes"])
.current_dir(test.bare_repo_path());
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"Failed to create main worktree:\n{}",
String::from_utf8_lossy(&output.stderr)
);
fs::write(test.config_path(), "skip-shell-integration-prompt = true\n").unwrap();
test
}
#[test]
fn test_bare_repo_worktree_path_prompt_auto_accept() {
let test = setup_unconfigured_nested_bare_repo();
let main_worktree = test.project_path().join("main");
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature", "--yes"])
.current_dir(&main_worktree);
assert_cmd_snapshot!(cmd);
});
let config_content = fs::read_to_string(test.config_path()).unwrap();
assert!(
!config_content.contains("worktree-path"),
"Config should NOT contain worktree-path — --yes should not auto-configure.\nConfig: {config_content}"
);
let bad_path = test.project_path().join(".git.feature");
assert!(
bad_path.exists(),
"Worktree should be at {:?} (unconfigured default path)",
bad_path
);
}
#[test]
fn test_bare_repo_worktree_path_prompt_non_interactive_warning() {
let test = setup_unconfigured_nested_bare_repo();
let main_worktree = test.project_path().join("main");
let settings = setup_temp_snapshot_settings(test.temp_path());
settings.bind(|| {
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = wt_command();
test.configure_wt_cmd(&mut cmd);
configure_directive_files(&mut cmd, &cd_path, &exec_path);
cmd.args(["switch", "--create", "feature"])
.current_dir(&main_worktree);
assert_cmd_snapshot!(cmd);
});
}
#[cfg(all(unix, feature = "shell-integration-tests"))]
mod bare_repo_prompt_pty {
use super::*;
use crate::common::pty::{build_pty_command, exec_cmd_in_pty_prompted};
use crate::common::{add_pty_binary_path_filters, add_pty_filters, wt_bin};
use insta::assert_snapshot;
fn prompt_pty_settings(temp_path: &Path) -> insta::Settings {
let mut settings = setup_temp_snapshot_settings(temp_path);
add_pty_filters(&mut settings);
add_pty_binary_path_filters(&mut settings);
settings
}
#[test]
fn test_bare_repo_worktree_path_prompt_accept_pty() {
let test = setup_unconfigured_nested_bare_repo();
let main_worktree = test.project_path().join("main");
let env_vars = test.test_env_vars();
let cmd = build_pty_command(
wt_bin().to_str().unwrap(),
&["switch", "--create", "feature"],
&main_worktree,
&env_vars,
None,
);
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["y\n"], "[y/N");
assert_eq!(exit_code, 0);
prompt_pty_settings(test.temp_path()).bind(|| {
assert_snapshot!("bare_repo_prompt_accept", &output);
});
let config_content = fs::read_to_string(test.config_path()).unwrap();
assert!(
config_content.contains("worktree-path"),
"Config should contain worktree-path override.\nConfig: {config_content}"
);
}
#[test]
fn test_bare_repo_worktree_path_prompt_decline_pty() {
let test = setup_unconfigured_nested_bare_repo();
let main_worktree = test.project_path().join("main");
let env_vars = test.test_env_vars();
let cmd = build_pty_command(
wt_bin().to_str().unwrap(),
&["switch", "--create", "feature"],
&main_worktree,
&env_vars,
None,
);
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["n\n"], "[y/N");
assert_eq!(exit_code, 0);
prompt_pty_settings(test.temp_path()).bind(|| {
assert_snapshot!("bare_repo_prompt_decline", &output);
});
let git_config_output = Cmd::new("git")
.args(["config", "worktrunk.skip-bare-repo-prompt"])
.current_dir(&main_worktree)
.env("GIT_CONFIG_GLOBAL", test.git_config_path())
.run()
.unwrap();
let value = String::from_utf8_lossy(&git_config_output.stdout);
assert_eq!(
value.trim(),
"true",
"Skip flag should be saved in git config"
);
}
#[test]
fn test_bare_repo_worktree_path_prompt_preview_pty() {
let test = setup_unconfigured_nested_bare_repo();
let main_worktree = test.project_path().join("main");
let env_vars = test.test_env_vars();
let cmd = build_pty_command(
wt_bin().to_str().unwrap(),
&["switch", "--create", "feature"],
&main_worktree,
&env_vars,
None,
);
let (output, exit_code) = exec_cmd_in_pty_prompted(cmd, &["?\n", "n\n"], "[y/N");
assert_eq!(exit_code, 0);
prompt_pty_settings(test.temp_path()).bind(|| {
assert_snapshot!("bare_repo_prompt_preview", &output);
});
}
}