mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
1eded27943
This consolidates cross-cutting models that had accumulated parallel representations, while preserving the CLI and config interfaces. ## What changed - Forge identity now flows through one `ForgeKind`, with boundary-safe network-host classification shared by CI, remote references, and structured repository metadata. Branded SSH aliases such as `github-personal` remain outside provider dispatch and receive an actionable `forge.platform` diagnostic when forge data is requested. - Worktree removal now uses one owned target type and rechecks uncached worktree topology immediately before compare-and-swap branch deletion, retaining branches that gained a live or locked checkout. - Shell integration no longer implements the retired single-file directive writer. Stale wrappers receive repair guidance, execution fails closed, and child processes cannot inherit the retired sourceable-file capability. - Zsh and Git-version probes are shared, while dead mocks, redundant dependency edges, serializer detours, pass-through types, and duplicate tests are removed. The removal guard deliberately distinguishes live, stale-prunable, and locked registrations. The detached cleanup path performs the same record-aware check before deleting a branch ref. ## Testing `cargo run -- hook pre-merge --yes` passed after merging current `origin/main`: 4,489 tests, Clippy, formatting, lockfile checks, docs, doctests, and snapshot review. > _This was written by Claude Code on behalf of max_.
954 lines
33 KiB
Rust
954 lines
33 KiB
Rust
use crate::common::{
|
|
TestRepo, configure_directive_cd_only, configure_directive_files, directive_files, repo,
|
|
repo_with_feature_worktree, repo_with_remote, repo_with_remote_and_feature,
|
|
setup_snapshot_settings, wt_command,
|
|
};
|
|
use insta_cmd::assert_cmd_snapshot;
|
|
use rstest::rstest;
|
|
use std::fs;
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs as unix_fs;
|
|
use std::path::Path;
|
|
|
|
// ============================================================================
|
|
// Directive File Tests (split protocol)
|
|
// ============================================================================
|
|
// These tests verify the split directive-file protocol:
|
|
// - WORKTRUNK_DIRECTIVE_CD_FILE: wt writes a raw path (no `cd ` prefix, no quotes).
|
|
// The shell wrapper runs `cd -- "$(< file)"`.
|
|
// - WORKTRUNK_DIRECTIVE_EXEC_FILE: wt writes arbitrary shell (e.g. from --execute).
|
|
// The shell wrapper sources the file.
|
|
|
|
#[rstest]
|
|
fn test_switch_retired_directive_file_refuses_execute_after_switch(
|
|
#[from(repo_with_remote)] mut repo: TestRepo,
|
|
) {
|
|
let feature_wt = repo.add_worktree("feature");
|
|
let directive_dir = tempfile::TempDir::new().unwrap();
|
|
let directive_path = directive_dir.path().join("directive");
|
|
let execute_marker = feature_wt.join("retired-execute-ran");
|
|
fs::write(&directive_path, "").unwrap();
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
cmd.env("WORKTRUNK_DIRECTIVE_FILE", &directive_path);
|
|
cmd.arg("switch")
|
|
.arg("feature")
|
|
.arg("--execute")
|
|
.arg("echo spawned > retired-execute-ran")
|
|
.current_dir(repo.root_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
assert!(
|
|
!execute_marker.exists(),
|
|
"wt must never spawn an --execute payload from the retired wrapper"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&directive_path).unwrap(),
|
|
"",
|
|
"wt must never write to the retired directive file"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_switch_retired_directive_file_refuses_execute_when_already_at(
|
|
#[from(repo_with_remote)] mut repo: TestRepo,
|
|
) {
|
|
let feature_wt = repo.add_worktree("feature");
|
|
let directive_dir = tempfile::TempDir::new().unwrap();
|
|
let directive_path = directive_dir.path().join("directive");
|
|
let execute_marker = feature_wt.join("retired-execute-ran");
|
|
fs::write(&directive_path, "").unwrap();
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
cmd.env("WORKTRUNK_DIRECTIVE_FILE", &directive_path);
|
|
cmd.arg("switch")
|
|
.arg("feature")
|
|
.arg("--execute")
|
|
.arg("echo spawned > retired-execute-ran")
|
|
.current_dir(&feature_wt);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
assert!(
|
|
!execute_marker.exists(),
|
|
"wt must never spawn an --execute payload from the retired wrapper"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&directive_path).unwrap(),
|
|
"",
|
|
"wt must never write to the retired directive file"
|
|
);
|
|
});
|
|
}
|
|
|
|
/// A current split wrapper may inherit the retired variable from an older
|
|
/// parent shell. The split files still take precedence: both directives land
|
|
/// in their dedicated files, and the retired file remains untouched.
|
|
#[rstest]
|
|
fn test_split_directive_files_win_over_retired_variable(
|
|
#[from(repo_with_remote)] mut repo: TestRepo,
|
|
) {
|
|
let _feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let retired_dir = tempfile::TempDir::new().unwrap();
|
|
let retired_path = retired_dir.path().join("directive");
|
|
fs::write(&retired_path, "").unwrap();
|
|
|
|
let mut cmd = repo.wt_command();
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
let output = cmd
|
|
.env("WORKTRUNK_DIRECTIVE_FILE", &retired_path)
|
|
.args(["switch", "feature", "--execute", "echo through-split"])
|
|
.current_dir(repo.root_path())
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"split directives must remain active when the retired variable is also set.\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(
|
|
!fs::read_to_string(&cd_path)
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.is_empty(),
|
|
"the split CD file must receive the target path"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&exec_path).unwrap(),
|
|
"echo through-split\n",
|
|
"the split EXEC file must receive the command"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(&retired_path).unwrap(),
|
|
"",
|
|
"wt must never write to the retired directive file"
|
|
);
|
|
}
|
|
|
|
/// When only the CD file is set (EXEC scrubbed — running inside an alias/hook),
|
|
/// --execute commands are refused with a warning.
|
|
#[rstest]
|
|
fn test_switch_exec_scrubbed_warns(#[from(repo_with_remote)] repo: TestRepo) {
|
|
let (cd_path, _exec_path, _guard) = directive_files();
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
// Only set CD file, not EXEC — simulates running inside alias/hook body
|
|
configure_directive_cd_only(&mut cmd, &cd_path);
|
|
cmd.args([
|
|
"switch",
|
|
"--create",
|
|
"scrub-test",
|
|
"--execute",
|
|
"echo should-not-run",
|
|
])
|
|
.current_dir(repo.root_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Split Protocol Tests
|
|
// ============================================================================
|
|
|
|
#[rstest]
|
|
fn test_switch_directive_file(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
let _feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.arg("switch")
|
|
.arg("feature")
|
|
.current_dir(repo.root_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
// Verify cd file contains a raw path (no `cd ` prefix, no quotes)
|
|
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
!cd_content.trim().is_empty(),
|
|
"CD file should contain a path, got: {}",
|
|
cd_content
|
|
);
|
|
assert!(
|
|
!cd_content.contains("cd "),
|
|
"CD file should contain a raw path (no cd prefix), got: {}",
|
|
cd_content
|
|
);
|
|
});
|
|
}
|
|
|
|
/// A failed EXEC-directive write names the file and the write.
|
|
///
|
|
/// This write is the last thing a `--switch --execute` does, after the switch
|
|
/// has already landed and after `◎ Executing (--execute):`, so the bare
|
|
/// `io::Error` it used to propagate (`✗ No such file or directory (os error 2)`)
|
|
/// read as if the *command* were missing. The paths come from the shell wrapper,
|
|
/// so the message has to point there.
|
|
#[rstest]
|
|
fn test_exec_directive_write_failure_names_the_file(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
let _feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
// A path under a directory that doesn't exist: the open fails, and nothing
|
|
// about it depends on platform or timing.
|
|
let unwritable_exec = exec_path.parent().unwrap().join("no-such-dir").join("exec");
|
|
|
|
let mut cmd = repo.wt_command();
|
|
configure_directive_files(&mut cmd, &cd_path, &unwritable_exec);
|
|
let output = cmd
|
|
.args(["switch", "feature", "--execute", "echo hi"])
|
|
.current_dir(repo.root_path())
|
|
.output()
|
|
.unwrap();
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
!output.status.success(),
|
|
"an unwritable exec directive file must fail the command.\nstderr:\n{stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("Failed to write the command to the directive file"),
|
|
"the error must name the write and the file.\nstderr:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
/// A failed CD-directive write names the file too, for the same reason.
|
|
#[rstest]
|
|
fn test_cd_directive_write_failure_names_the_file(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
let _feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
let unwritable_cd = cd_path.parent().unwrap().join("no-such-dir").join("cd");
|
|
|
|
let mut cmd = repo.wt_command();
|
|
configure_directive_files(&mut cmd, &unwritable_cd, &exec_path);
|
|
let output = cmd
|
|
.args(["switch", "feature"])
|
|
.current_dir(repo.root_path())
|
|
.output()
|
|
.unwrap();
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
!output.status.success(),
|
|
"an unwritable cd directive file must fail the command.\nstderr:\n{stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains("Failed to write the cd directive file"),
|
|
"the error must name the write and the file.\nstderr:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_merge_directive_file(mut repo_with_remote_and_feature: TestRepo) {
|
|
let repo = &mut repo_with_remote_and_feature;
|
|
let feature_wt = &repo.worktrees["feature"];
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
let settings = setup_snapshot_settings(repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.arg("merge").arg("main").current_dir(feature_wt);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
// Verify cd file contains a raw path (back to main)
|
|
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
!cd_content.trim().is_empty(),
|
|
"CD file should contain a path, got: {}",
|
|
cd_content
|
|
);
|
|
assert!(
|
|
!cd_content.contains("cd "),
|
|
"CD file should contain a raw path (no cd prefix), got: {}",
|
|
cd_content
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_remove_directive_file(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
let feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.arg("remove").current_dir(&feature_wt);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
// Verify cd file contains a raw path (back to main)
|
|
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
!cd_content.trim().is_empty(),
|
|
"CD file should contain a path, got: {}",
|
|
cd_content
|
|
);
|
|
assert!(
|
|
!cd_content.contains("cd "),
|
|
"CD file should contain a raw path (no cd prefix), got: {}",
|
|
cd_content
|
|
);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Subdirectory Preservation Tests
|
|
// ============================================================================
|
|
// These tests verify that switching preserves the user's subdirectory position
|
|
|
|
#[rstest]
|
|
fn test_switch_preserves_subdir(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
let feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Create the same subdirectory in both worktrees
|
|
let subdir = "apps/gateway";
|
|
fs::create_dir_all(repo.root_path().join(subdir)).unwrap();
|
|
fs::create_dir_all(feature_wt.join(subdir)).unwrap();
|
|
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.arg("switch")
|
|
.arg("feature")
|
|
.current_dir(repo.root_path().join(subdir));
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(output.status.success(), "wt switch failed: {:?}", output);
|
|
|
|
// Verify cd file contains path to the subdirectory, not the root.
|
|
// Use Path::join for each component so separators are native on Windows.
|
|
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
|
|
let expected_subdir = feature_wt.join(Path::new("apps").join("gateway"));
|
|
let expected_str = expected_subdir.to_string_lossy();
|
|
assert!(
|
|
cd_content.contains(&*expected_str),
|
|
"CD file should contain subdirectory path {}, got: {}",
|
|
expected_str,
|
|
cd_content
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_switch_falls_back_to_root_when_subdir_missing(
|
|
#[from(repo_with_remote)] mut repo: TestRepo,
|
|
) {
|
|
let feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Create subdirectory only in the source worktree, not in the target
|
|
let subdir = "apps/gateway";
|
|
fs::create_dir_all(repo.root_path().join(subdir)).unwrap();
|
|
// Intentionally NOT creating the subdir in feature_wt
|
|
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.arg("switch")
|
|
.arg("feature")
|
|
.current_dir(repo.root_path().join(subdir));
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(output.status.success(), "wt switch failed: {:?}", output);
|
|
|
|
// Verify cd file contains path to worktree root (not the missing subdir)
|
|
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
|
|
let feature_str = feature_wt.to_string_lossy();
|
|
assert!(
|
|
cd_content.contains(&*feature_str),
|
|
"CD file should contain worktree root {}, got: {}",
|
|
feature_str,
|
|
cd_content
|
|
);
|
|
// Make sure it doesn't contain the subdir path.
|
|
// Use Path::join for each component so separators are native on Windows.
|
|
let subdir_path = feature_wt.join(Path::new("apps").join("gateway"));
|
|
let subdir_str = subdir_path.to_string_lossy();
|
|
assert!(
|
|
!cd_content.contains(&*subdir_str),
|
|
"CD file should NOT contain missing subdirectory path {}, got: {}",
|
|
subdir_str,
|
|
cd_content
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_switch_create_preserves_subdir(#[from(repo_with_remote)] repo: TestRepo) {
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Create a subdirectory in the source worktree and commit it so it appears in the new branch
|
|
let subdir = "apps/gateway";
|
|
fs::create_dir_all(repo.root_path().join(subdir)).unwrap();
|
|
// Add a file so git tracks the directory
|
|
fs::write(repo.root_path().join(subdir).join(".gitkeep"), "").unwrap();
|
|
repo.commit("Add apps/gateway");
|
|
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "new-feature"])
|
|
.current_dir(repo.root_path().join(subdir));
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(output.status.success(), "wt switch failed: {:?}", output);
|
|
|
|
// The subdirectory was committed, so the new worktree should have it.
|
|
// Use Path to construct the expected substring so separators match on Windows.
|
|
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
|
|
let subdir_suffix = Path::new("apps").join("gateway");
|
|
let subdir_str = subdir_suffix.to_string_lossy();
|
|
assert!(
|
|
cd_content.contains(&*subdir_str),
|
|
"CD file should contain preserved subdirectory path, got: {}",
|
|
cd_content
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_remove_preserves_subdir(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
let feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Create the same subdirectory in both the feature worktree (where the
|
|
// user is) and the main worktree (where removal lands).
|
|
let subdir = "apps/gateway";
|
|
fs::create_dir_all(repo.root_path().join(subdir)).unwrap();
|
|
fs::create_dir_all(feature_wt.join(subdir)).unwrap();
|
|
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.arg("remove").current_dir(feature_wt.join(subdir));
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(output.status.success(), "wt remove failed: {:?}", output);
|
|
|
|
// Verify cd file lands in the equivalent subdirectory of the main
|
|
// worktree, not at its root — mirroring `wt switch` (issue #3343).
|
|
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
|
|
let expected_subdir = repo.root_path().join(Path::new("apps").join("gateway"));
|
|
let expected_str = expected_subdir.to_string_lossy();
|
|
assert!(
|
|
cd_content.contains(&*expected_str),
|
|
"CD file should contain subdirectory path {}, got: {}",
|
|
expected_str,
|
|
cd_content
|
|
);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_remove_falls_back_to_root_when_subdir_missing(
|
|
#[from(repo_with_remote)] mut repo: TestRepo,
|
|
) {
|
|
let feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Create the subdirectory only in the feature worktree (where the user
|
|
// is), not in the main worktree where removal lands.
|
|
let subdir = "apps/gateway";
|
|
fs::create_dir_all(feature_wt.join(subdir)).unwrap();
|
|
// Intentionally NOT creating the subdir in the main worktree.
|
|
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.arg("remove").current_dir(feature_wt.join(subdir));
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(output.status.success(), "wt remove failed: {:?}", output);
|
|
|
|
// Verify cd file lands at the main worktree root (the subdir is absent
|
|
// there, so preservation falls back).
|
|
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
|
|
let root_str = repo.root_path().to_string_lossy();
|
|
assert!(
|
|
cd_content.contains(&*root_str),
|
|
"CD file should contain main worktree root {}, got: {}",
|
|
root_str,
|
|
cd_content
|
|
);
|
|
let subdir_path = repo.root_path().join(Path::new("apps").join("gateway"));
|
|
let subdir_str = subdir_path.to_string_lossy();
|
|
assert!(
|
|
!cd_content.contains(&*subdir_str),
|
|
"CD file should NOT contain missing subdirectory path {}, got: {}",
|
|
subdir_str,
|
|
cd_content
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// --no-cd Tests
|
|
// ============================================================================
|
|
// These tests verify that --no-cd suppresses directory changes
|
|
|
|
#[rstest]
|
|
fn test_switch_no_cd_suppresses_directive(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
let _feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "feature", "--no-cd"])
|
|
.current_dir(repo.root_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
// Verify cd file is empty (no path written with --no-cd)
|
|
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
cd_content.trim().is_empty(),
|
|
"CD file should be empty with --no-cd, got: {}",
|
|
cd_content
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_switch_no_cd_create_suppresses_directive(#[from(repo_with_remote)] repo: TestRepo) {
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "--create", "new-feature", "--no-cd"])
|
|
.current_dir(repo.root_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
// Verify cd file is empty (no path written with --no-cd)
|
|
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
cd_content.trim().is_empty(),
|
|
"CD file should be empty with --no-cd, got: {}",
|
|
cd_content
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_switch_no_cd_hooks_show_path_annotation(#[from(repo_with_remote)] repo: TestRepo) {
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Create project config with a post-switch hook
|
|
let config_dir = repo.root_path().join(".config");
|
|
fs::create_dir_all(&config_dir).unwrap();
|
|
fs::write(
|
|
config_dir.join("wt.toml"),
|
|
"post-switch = \"echo switched\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
repo.commit("Add config");
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
// Use --yes to auto-approve the hook command
|
|
cmd.args(["switch", "--create", "hook-test", "--no-cd", "--yes"])
|
|
.current_dir(repo.root_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
// Verify cd file is empty (no path written with --no-cd)
|
|
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
cd_content.trim().is_empty(),
|
|
"CD file should be empty with --no-cd, got: {}",
|
|
cd_content
|
|
);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_switch_no_cd_execute_runs_in_target_worktree(#[from(repo_with_remote)] repo: TestRepo) {
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
// pwd should print the target worktree path, even with --no-cd
|
|
cmd.args([
|
|
"switch",
|
|
"--create",
|
|
"exec-test",
|
|
"--no-cd",
|
|
"--execute",
|
|
"pwd",
|
|
])
|
|
.current_dir(repo.root_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
// Verify cd file is empty (no path written with --no-cd)
|
|
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
cd_content.trim().is_empty(),
|
|
"CD file should be empty with --no-cd, got: {}",
|
|
cd_content
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Config-driven no-cd suppresses the cd directive (same as --no-cd flag)
|
|
#[rstest]
|
|
fn test_switch_no_cd_config_suppresses_directive(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
let _feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Set up config with cd = false
|
|
repo.write_test_config(
|
|
r#"worktree-path = "../{{ repo }}.{{ branch }}"
|
|
|
|
[switch]
|
|
cd = false
|
|
"#,
|
|
);
|
|
|
|
let settings = setup_snapshot_settings(&repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.args(["switch", "feature"])
|
|
.current_dir(repo.root_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
// Verify cd file is empty (no path written with cd=false config)
|
|
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
cd_content.trim().is_empty(),
|
|
"CD file should be empty with no-cd config, got: {}",
|
|
cd_content
|
|
);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Non-Directive Mode Tests (no split directive env vars)
|
|
// ============================================================================
|
|
|
|
#[rstest]
|
|
fn test_switch_without_directive_file(repo: TestRepo) {
|
|
let settings = setup_snapshot_settings(&repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
cmd.arg("switch")
|
|
.arg("my-feature")
|
|
.current_dir(repo.root_path());
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_merge_directive_no_remove(mut repo_with_feature_worktree: TestRepo) {
|
|
let repo = &mut repo_with_feature_worktree;
|
|
let feature_wt = &repo.worktrees["feature"];
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
let settings = setup_snapshot_settings(repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.arg("merge")
|
|
.arg("main")
|
|
.arg("--no-remove")
|
|
.current_dir(feature_wt);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_merge_directive_remove(mut repo_with_feature_worktree: TestRepo) {
|
|
let repo = &mut repo_with_feature_worktree;
|
|
let feature_wt = &repo.worktrees["feature"];
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
let settings = setup_snapshot_settings(repo);
|
|
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.arg("merge").arg("main").current_dir(feature_wt);
|
|
|
|
assert_cmd_snapshot!(cmd);
|
|
|
|
// Verify cd file contains a raw path
|
|
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
!cd_content.trim().is_empty(),
|
|
"CD file should contain a path, got: {}",
|
|
cd_content
|
|
);
|
|
assert!(
|
|
!cd_content.contains("cd "),
|
|
"CD file should contain a raw path (no cd prefix), got: {}",
|
|
cd_content
|
|
);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Symlink Path Preservation Tests
|
|
// ============================================================================
|
|
// These tests verify that cd directives use the logical (symlink) path
|
|
// instead of the canonical path when the user navigates via symlinks.
|
|
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
fn test_switch_preserves_symlink_path(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
let _feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Create a symlink to the repo's parent directory
|
|
let real_parent = repo.root_path().parent().unwrap();
|
|
let symlink_dir = tempfile::tempdir().unwrap();
|
|
let symlink_path = symlink_dir.path().join("link");
|
|
unix_fs::symlink(real_parent, &symlink_path).unwrap();
|
|
|
|
// Construct the symlinked path to the repo
|
|
let repo_dir_name = repo.root_path().file_name().unwrap();
|
|
let logical_cwd = symlink_path.join(repo_dir_name);
|
|
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
// Set PWD to the logical (symlink) path — this is what the shell sets
|
|
cmd.env("PWD", &logical_cwd);
|
|
cmd.arg("switch").arg("feature").current_dir(&logical_cwd);
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"wt switch failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// The cd file should use the logical (symlink) path, not the canonical one
|
|
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
|
|
|
|
// The symlink prefix should appear in the cd path
|
|
let symlink_prefix = symlink_path.to_string_lossy();
|
|
assert!(
|
|
cd_content.contains(&*symlink_prefix),
|
|
"CD file should use symlink path (containing {}), got: {}",
|
|
symlink_prefix,
|
|
cd_content
|
|
);
|
|
|
|
// The canonical (real) parent path should NOT appear
|
|
let real_prefix = real_parent.to_string_lossy();
|
|
assert!(
|
|
!cd_content.contains(&*real_prefix),
|
|
"CD file should NOT contain canonical path {}, got: {}",
|
|
real_prefix,
|
|
cd_content
|
|
);
|
|
|
|
// Display messages (stderr) should also use the logical path
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains(&*symlink_prefix),
|
|
"Display message should contain logical path {}, got: {}",
|
|
symlink_prefix,
|
|
stderr
|
|
);
|
|
assert!(
|
|
!stderr.contains(&*real_prefix),
|
|
"Display message should NOT contain canonical path {}, got: {}",
|
|
real_prefix,
|
|
stderr
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
fn test_switch_create_preserves_symlink_path(#[from(repo_with_remote)] repo: TestRepo) {
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Create a symlink to the repo's parent directory
|
|
let real_parent = repo.root_path().parent().unwrap();
|
|
let symlink_dir = tempfile::tempdir().unwrap();
|
|
let symlink_path = symlink_dir.path().join("link");
|
|
unix_fs::symlink(real_parent, &symlink_path).unwrap();
|
|
|
|
let repo_dir_name = repo.root_path().file_name().unwrap();
|
|
let logical_cwd = symlink_path.join(repo_dir_name);
|
|
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.env("PWD", &logical_cwd);
|
|
cmd.args(["switch", "--create", "new-feature"])
|
|
.current_dir(&logical_cwd);
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"wt switch --create failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
|
|
let symlink_prefix = symlink_path.to_string_lossy();
|
|
assert!(
|
|
cd_content.contains(&*symlink_prefix),
|
|
"CD file should use symlink path (containing {}), got: {}",
|
|
symlink_prefix,
|
|
cd_content
|
|
);
|
|
|
|
// Display messages (stderr) should also use the logical path
|
|
let real_prefix = real_parent.to_string_lossy();
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains(&*symlink_prefix),
|
|
"Display message should contain logical path {}, got: {}",
|
|
symlink_prefix,
|
|
stderr
|
|
);
|
|
assert!(
|
|
!stderr.contains(&*real_prefix),
|
|
"Display message should NOT contain canonical path {}, got: {}",
|
|
real_prefix,
|
|
stderr
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
fn test_switch_preserves_symlink_path_from_subdirectory(
|
|
#[from(repo_with_remote)] mut repo: TestRepo,
|
|
) {
|
|
let feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
// Create subdirectory in both worktrees
|
|
let subdir = "apps/gateway";
|
|
fs::create_dir_all(repo.root_path().join(subdir)).unwrap();
|
|
fs::create_dir_all(feature_wt.join(subdir)).unwrap();
|
|
|
|
// Create a symlink to the repo's parent directory
|
|
let real_parent = repo.root_path().parent().unwrap();
|
|
let symlink_dir = tempfile::tempdir().unwrap();
|
|
let symlink_path = symlink_dir.path().join("link");
|
|
unix_fs::symlink(real_parent, &symlink_path).unwrap();
|
|
|
|
let repo_dir_name = repo.root_path().file_name().unwrap();
|
|
let logical_cwd = symlink_path.join(repo_dir_name).join(subdir);
|
|
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
cmd.env("PWD", &logical_cwd);
|
|
cmd.arg("switch").arg("feature").current_dir(&logical_cwd);
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"wt switch failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
|
|
|
|
// Should use symlink prefix AND preserve subdirectory
|
|
let symlink_prefix = symlink_path.to_string_lossy();
|
|
assert!(
|
|
cd_content.contains(&*symlink_prefix),
|
|
"CD file should use symlink path (containing {}), got: {}",
|
|
symlink_prefix,
|
|
cd_content
|
|
);
|
|
|
|
let subdir_suffix = Path::new("apps").join("gateway");
|
|
let subdir_str = subdir_suffix.to_string_lossy();
|
|
assert!(
|
|
cd_content.contains(&*subdir_str),
|
|
"CD file should preserve subdirectory {}, got: {}",
|
|
subdir_str,
|
|
cd_content
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[rstest]
|
|
fn test_switch_no_symlink_uses_canonical(#[from(repo_with_remote)] mut repo: TestRepo) {
|
|
// When PWD matches current_dir (no symlink), canonical path is used as before
|
|
let _feature_wt = repo.add_worktree("feature");
|
|
let (cd_path, exec_path, _guard) = directive_files();
|
|
|
|
let canonical_cwd = dunce::canonicalize(repo.root_path()).unwrap();
|
|
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
configure_directive_files(&mut cmd, &cd_path, &exec_path);
|
|
// Set PWD to canonical (same as current_dir — no symlink)
|
|
cmd.env("PWD", &canonical_cwd);
|
|
cmd.arg("switch").arg("feature").current_dir(&canonical_cwd);
|
|
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"wt switch failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// Should still have a path in the cd file (just with canonical path)
|
|
let cd_content = fs::read_to_string(&cd_path).unwrap_or_default();
|
|
assert!(
|
|
!cd_content.trim().is_empty(),
|
|
"CD file should contain a path, got: {}",
|
|
cd_content
|
|
);
|
|
}
|