use crate::common::{
TestRepo, make_snapshot_cmd, merge_scenario,
mock_commands::{create_mock_cargo, create_mock_llm_auth},
repo, repo_with_alternate_primary, repo_with_feature_worktree, repo_with_main_worktree,
repo_with_multi_commit_feature, setup_snapshot_settings, wait_for_file, wait_for_file_content,
};
use insta::assert_snapshot;
use insta_cmd::assert_cmd_snapshot;
use path_slash::PathExt as _;
use rstest::rstest;
use std::fs;
use std::path::{Path, PathBuf};
fn make_path_with_mock_bin(bin_dir: &Path) -> (String, String) {
let (path_var_name, current_path) = std::env::vars_os()
.find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
.map(|(k, v)| (k.to_string_lossy().into_owned(), Some(v)))
.unwrap_or(("PATH".to_string(), None));
let mut paths: Vec<PathBuf> = current_path
.as_deref()
.map(|p| std::env::split_paths(p).collect())
.unwrap_or_default();
paths.insert(0, bin_dir.to_path_buf());
let new_path = std::env::join_paths(&paths)
.unwrap()
.to_string_lossy()
.into_owned();
(path_var_name, new_path)
}
fn snapshot_merge_with_env(
test_name: &str,
repo: &TestRepo,
args: &[&str],
cwd: Option<&Path>,
env_vars: &[(&str, &str)],
) {
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "merge", args, cwd);
for (key, value) in env_vars {
cmd.env(key, value);
}
assert_cmd_snapshot!(test_name, cmd);
});
}
#[rstest]
fn test_merge_fast_forward(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_as_git_subcommand(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "merge", &["main"], Some(&feature_wt));
cmd.env("GIT_EXEC_PATH", "/usr/lib/git-core");
cmd
});
}
#[rstest]
fn test_merge_primary_not_on_default_with_default_worktree(
mut repo_with_alternate_primary: TestRepo,
) {
let repo = &mut repo_with_alternate_primary;
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_with_no_remove_flag(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--no-remove"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_already_on_target(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "merge", &[], None));
}
#[rstest]
fn test_merge_with_stale_default_branch_cache(mut repo: TestRepo) {
repo.run_git(&["config", "worktrunk.default-branch", "nonexistent"]);
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "merge", &[], Some(&feature_wt)));
}
#[rstest]
fn test_merge_from_primary_worktree_to_other_branch(mut repo: TestRepo) {
let feature_wt = repo.add_feature();
drop(feature_wt); assert_cmd_snapshot!(make_snapshot_cmd(&repo, "merge", &["feature"], None));
}
#[rstest]
fn test_merge_dirty_working_tree(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
std::fs::write(feature_wt.join("dirty.txt"), "uncommitted content").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_not_fast_forward(mut repo: TestRepo) {
std::fs::write(repo.root_path().join("main.txt"), "main content").unwrap();
repo.run_git(&["add", "main.txt"]);
repo.run_git(&["commit", "-m", "Add main file"]);
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_no_commit_not_fast_forward(repo: TestRepo) {
let initial_sha = String::from_utf8_lossy(
&repo
.git_command()
.args(["rev-parse", "HEAD"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
std::fs::write(repo.root_path().join("main.txt"), "main content").unwrap();
repo.run_git(&["add", "main.txt"]);
repo.run_git(&["commit", "-m", "Add main file"]);
let feature_path = repo.root_path().parent().unwrap().join("feature");
repo.run_git(&[
"worktree",
"add",
"-b",
"feature",
feature_path.to_str().unwrap(),
&initial_sha,
]);
std::fs::write(feature_path.join("feature.txt"), "feature content").unwrap();
repo.run_git_in(&feature_path, &["add", "feature.txt"]);
repo.run_git_in(&feature_path, &["commit", "-m", "Add feature file"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--no-commit", "--no-remove"],
Some(&feature_path)
));
}
#[rstest]
fn test_merge_rebase_conflict(repo: TestRepo) {
std::fs::write(repo.root_path().join("shared.txt"), "initial content\n").unwrap();
repo.run_git(&["add", "shared.txt"]);
repo.commit("Add shared file");
std::fs::write(repo.root_path().join("shared.txt"), "main version\n").unwrap();
repo.run_git(&["add", "shared.txt"]);
repo.run_git(&["commit", "-m", "Update shared.txt in main"]);
let base_commit = String::from_utf8_lossy(
&repo
.git_command()
.args(["rev-parse", "HEAD~1"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
let feature_wt = repo.root_path().parent().unwrap().join("repo.feature");
repo.run_git(&[
"worktree",
"add",
feature_wt.to_str().unwrap(),
"-b",
"feature",
&base_commit,
]);
std::fs::write(feature_wt.join("shared.txt"), "feature version\n").unwrap();
repo.run_git_in(&feature_wt, &["add", "shared.txt"]);
repo.run_git_in(
&feature_wt,
&["commit", "-m", "Update shared.txt in feature"],
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_to_default_branch(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "merge", &[], Some(&feature_wt)));
}
#[rstest]
fn test_merge_with_caret_symbol(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "merge", &["^"], Some(&feature_wt)));
}
#[rstest]
fn test_merge_error_detached_head(repo: TestRepo) {
repo.detach_head();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main"],
Some(repo.root_path())
));
}
#[rstest]
fn test_merge_squash_deterministic(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(&feature_wt, "file1.txt", "content 1", "feat: add file 1");
repo.commit_in_worktree(&feature_wt, "file2.txt", "content 2", "fix: update logic");
repo.commit_in_worktree(&feature_wt, "file3.txt", "content 3", "docs: update readme");
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_squash_with_llm(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(
&feature_wt,
"auth.txt",
"auth module",
"feat: add authentication",
);
repo.commit_in_worktree(
&feature_wt,
"auth.txt",
"auth module updated",
"fix: handle edge case",
);
let worktrunk_config = r#"
[commit.generation]
command = "cat >/dev/null && echo 'feat: implement user authentication system'"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_squash_llm_command_not_found(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(&feature_wt, "file1.txt", "content 1", "feat: new feature");
repo.commit_in_worktree(&feature_wt, "file2.txt", "content 2", "fix: bug fix");
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(repo, "merge", &["main"], Some(&feature_wt));
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"nonexistent-llm-command",
);
cmd
});
}
#[rstest]
fn test_merge_squash_llm_error(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(&feature_wt, "file1.txt", "content 1", "feat: new feature");
repo.commit_in_worktree(&feature_wt, "file2.txt", "content 2", "fix: bug fix");
let worktrunk_config = r#"
[commit.generation]
command = "cat > /dev/null; echo 'Error: connection refused' >&2 && exit 1"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_squash_single_commit(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt =
repo.add_worktree_with_commit("feature", "file1.txt", "content", "feat: single commit");
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_no_squash(repo_with_multi_commit_feature: TestRepo) {
let repo = &repo_with_multi_commit_feature;
let feature_wt = &repo.worktrees["feature"];
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-squash"],
Some(feature_wt)
));
}
#[rstest]
fn test_merge_squash_empty_changes(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("feature");
let file_path = feature_wt.join("file.txt");
let initial_content = std::fs::read_to_string(&file_path).unwrap();
repo.commit_in_worktree(&feature_wt, "file.txt", "change1", "Change 1");
repo.commit_in_worktree(&feature_wt, "file.txt", "change2", "Change 2");
repo.commit_in_worktree(
&feature_wt,
"file.txt",
&initial_content,
"Revert to initial",
);
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_auto_commit_deterministic(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree_with_commit(
"feature",
"feature.txt",
"initial content",
"feat: initial feature",
);
std::fs::write(feature_wt.join("feature.txt"), "modified content").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_auto_commit_with_llm(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree_with_commit(
"feature",
"auth.txt",
"initial auth",
"feat: add authentication",
);
std::fs::write(feature_wt.join("auth.txt"), "improved auth with validation").unwrap();
let worktrunk_config = r#"
[commit.generation]
command = "cat >/dev/null && echo 'fix: improve auth validation logic'"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_auto_commit_and_squash(repo_with_multi_commit_feature: TestRepo) {
let repo = &repo_with_multi_commit_feature;
let feature_wt = &repo.worktrees["feature"];
std::fs::write(feature_wt.join("file1.txt"), "updated content 1").unwrap();
let worktrunk_config = r#"
[commit.generation]
command = "cat >/dev/null && echo 'fix: update file 1 content'"
"#;
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(feature_wt)
));
}
#[rstest]
fn test_merge_with_untracked_files(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt =
repo.add_worktree_with_commit("feature", "file1.txt", "content 1", "feat: add file 1");
std::fs::write(feature_wt.join("untracked1.txt"), "untracked content 1").unwrap();
std::fs::write(feature_wt.join("untracked2.txt"), "untracked content 2").unwrap();
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(repo, "merge", &["main"], Some(&feature_wt));
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'fix: commit changes'",
);
cmd
});
}
#[rstest]
fn test_merge_pre_merge_command_success(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), r#"pre-merge = "exit 0""#).unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_pre_merge_command_failure(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), r#"pre-merge = "exit 1""#).unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_pre_merge_command_no_hooks(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), r#"pre-merge = "exit 1""#).unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--no-hooks"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_pre_merge_command_named(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"
pre-merge = [
{format = "exit 0"},
{lint = "exit 0"},
{test = "exit 0"},
]
"#,
)
.unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_post_merge_command_success(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-merge = "echo 'merged {{ branch }} to {{ target }}' > post-merge-ran.txt""#,
)
.unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
let marker_file = repo.root_path().join("post-merge-ran.txt");
wait_for_file_content(&marker_file);
let content = fs::read_to_string(&marker_file).unwrap();
assert!(
content.contains("merged feature to main"),
"Marker file should contain correct branch and target: {}",
content
);
}
#[rstest]
fn test_merge_post_merge_command_skipped_with_no_hooks(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-merge = "echo 'merged {{ branch }} to {{ target }}' > post-merge-ran.txt""#,
)
.unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes", "--no-hooks"],
Some(&feature_wt)
));
let marker_file = repo.root_path().join("post-merge-ran.txt");
assert!(
!marker_file.exists(),
"Post-merge command should not run when --no-hooks is set"
);
}
#[rstest]
fn test_merge_no_verify_deprecated_still_works(mut repo: TestRepo) {
let feature_wt = repo.add_feature();
let output = repo
.wt_command()
.args(["merge", "main", "--yes", "--no-verify"])
.current_dir(&feature_wt)
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--no-verify is deprecated"),
"Expected deprecation warning in stderr: {stderr}"
);
assert!(
stderr.contains("--no-hooks"),
"Expected --no-hooks suggestion in stderr: {stderr}"
);
}
#[rstest]
fn test_merge_post_merge_command_failure(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), r#"post-merge = "exit 1""#).unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
let mut cmd = make_snapshot_cmd(&repo, "merge", &["main", "--yes"], Some(&feature_wt));
cmd.env("PWD", repo.root_path());
assert_cmd_snapshot!(cmd);
}
#[rstest]
fn test_merge_cwd_removed_hint_fallback_to_list(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), r#"post-merge = "exit 1""#).unwrap();
repo.commit("Add config");
repo.run_git(&["config", "worktrunk.default-branch", "nonexistent"]);
let feature_wt = repo.add_feature();
let mut cmd = make_snapshot_cmd(&repo, "merge", &["main", "--yes"], Some(&feature_wt));
cmd.env("PWD", repo.root_path());
assert_cmd_snapshot!(cmd);
}
#[cfg(not(target_os = "windows"))]
#[rstest]
fn test_merge_cwd_removed_hint_no_recovery(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), r#"post-merge = "exit 1""#).unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
let mut cmd = make_snapshot_cmd(&repo, "merge", &["main", "--yes"], Some(&feature_wt));
cmd.env("PWD", &feature_wt);
assert_cmd_snapshot!(cmd);
}
#[rstest]
fn test_merge_post_merge_command_named(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"
[post-merge]
notify = "echo 'Merge to {{ target }} complete' > notify.txt"
deploy = "echo 'Deploying branch {{ branch }}' > deploy.txt"
"#,
)
.unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
let notify_file = repo.root_path().join("notify.txt");
let deploy_file = repo.root_path().join("deploy.txt");
wait_for_file(¬ify_file);
wait_for_file(&deploy_file);
}
#[rstest]
fn test_merge_post_merge_runs_with_nothing_to_merge(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-merge = "echo 'post-merge ran' > post-merge-ran.txt""#,
)
.unwrap();
repo.commit("Add config");
let feature_wt = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
let marker_file = repo.root_path().join("post-merge-ran.txt");
wait_for_file(&marker_file);
}
#[rstest]
fn test_merge_post_merge_runs_from_main_branch(repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-merge = "echo 'post-merge ran from main' > post-merge-ran.txt""#,
)
.unwrap();
repo.commit("Add config");
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "merge", &["--yes"], None));
let marker_file = repo.root_path().join("post-merge-ran.txt");
wait_for_file(&marker_file);
}
#[rstest]
fn test_merge_pre_commit_command_success(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"pre-commit = "echo 'Pre-commit check passed'""#,
)
.unwrap();
repo.commit("Add config");
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("feature.txt"), "feature content").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_pre_commit_command_failure(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), r#"pre-commit = "exit 1""#).unwrap();
repo.commit("Add config");
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("feature.txt"), "feature content").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_pre_squash_command_success(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
"pre-commit = \"echo 'Pre-commit check passed'\"",
)
.unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_pre_squash_command_failure(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("wt.toml"), r#"pre-commit = "exit 1""#).unwrap();
repo.commit("Add config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_pre_commit_collected_for_squash_clean_worktree(
repo_with_multi_commit_feature: TestRepo,
) {
let repo = &repo_with_multi_commit_feature;
let feature_wt = repo.worktrees["feature"].clone();
let config_dir = feature_wt.join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
"pre-commit = \"echo 'Pre-commit from squash'\"",
)
.unwrap();
repo.run_git_in(&feature_wt, &["add", ".config/wt.toml"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "Add config"]);
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_no_remote(#[from(repo_with_feature_worktree)] repo: TestRepo) {
let feature_wt = repo.worktree_path("feature");
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "merge", &[], Some(feature_wt)));
}
#[rstest]
fn test_readme_example_simple(repo: TestRepo) {
assert_cmd_snapshot!(
"readme_example_simple_switch",
make_snapshot_cmd(&repo, "switch", &["--create", "fix-auth"], None)
);
let feature_wt = repo.root_path().parent().unwrap().join("repo.fix-auth");
let auth_rs = r#"// JWT validation utilities
pub struct JwtClaims {
pub sub: String,
pub scope: String,
}
pub fn validate(token: &str) -> bool {
token.starts_with("Bearer ") && token.split('.').count() == 3
}
pub fn refresh(refresh_token: &str) -> String {
format!("{}::refreshed", refresh_token)
}
"#;
std::fs::write(feature_wt.join("auth.rs"), auth_rs).unwrap();
repo.run_git_in(&feature_wt, &["add", "auth.rs"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "Implement JWT validation"]);
assert_cmd_snapshot!(
"readme_example_simple",
make_snapshot_cmd(&repo, "merge", &["main"], Some(&feature_wt))
);
}
#[rstest]
fn test_readme_example_complex(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
let bin_dir = repo.root_path().join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
create_mock_cargo(&bin_dir);
create_mock_llm_auth(&bin_dir);
let config_content = r#"
pre-merge = [
{"test" = "cargo test"},
{"lint" = "cargo clippy"},
]
[post-merge]
"install" = "cargo install --path ."
"#;
fs::write(config_dir.join("wt.toml"), config_content).unwrap();
repo.run_git(&["add", ".config/wt.toml", ".bin"]);
repo.run_git(&["commit", "-m", "Add project automation config"]);
let feature_wt = repo.add_worktree("feature-auth");
let commit_one = r#"// Token refresh logic
pub fn refresh(secret: &str, expires_in: u32) -> String {
format!("{}::{}", secret, expires_in)
}
pub fn needs_rotation(issued_at: u64, ttl: u64, now: u64) -> bool {
now.saturating_sub(issued_at) > ttl
}
"#;
std::fs::write(feature_wt.join("auth.rs"), commit_one).unwrap();
repo.run_git_in(&feature_wt, &["add", "auth.rs"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "Add token refresh logic"]);
let commit_two = r#"// JWT validation
pub fn validate_signature(payload: &str, signature: &str) -> bool {
!payload.is_empty() && signature.len() > 12
}
pub fn decode_claims(token: &str) -> Option<&str> {
token.split('.').nth(1)
}
"#;
std::fs::write(feature_wt.join("jwt.rs"), commit_two).unwrap();
repo.run_git_in(&feature_wt, &["add", "jwt.rs"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "Implement JWT validation"]);
let commit_three = r#"// Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refresh_rotates_secret() {
let token = refresh("token", 30);
assert!(token.contains("token::30"));
}
#[test]
fn decode_claims_returns_payload() {
let token = "header.payload.signature";
assert_eq!(decode_claims(token), Some("payload"));
}
}
"#;
std::fs::write(feature_wt.join("auth_test.rs"), commit_three).unwrap();
repo.run_git_in(&feature_wt, &["add", "auth_test.rs"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "Add authentication tests"]);
let llm_name = if cfg!(windows) { "llm.exe" } else { "llm" };
let llm_path = bin_dir.join(llm_name);
let llm_path_str = llm_path.to_slash_lossy();
let worktrunk_config = format!(
r#"
[commit.generation]
command = "{llm_path_str}"
"#
);
fs::write(repo.test_config_path(), worktrunk_config).unwrap();
let (path_var, path_with_bin) = make_path_with_mock_bin(&bin_dir);
let bin_dir_str = bin_dir.to_string_lossy();
snapshot_merge_with_env(
"readme_example_complex",
&repo,
&["main", "--yes"],
Some(&feature_wt),
&[
(&path_var, &path_with_bin),
("MOCK_CONFIG_DIR", &bin_dir_str),
],
);
}
#[rstest]
fn test_merge_no_commit_with_clean_tree(mut repo_with_feature_worktree: TestRepo) {
let repo = &mut repo_with_feature_worktree;
let feature_wt = &repo.worktrees["feature"];
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-commit", "--no-remove"],
Some(feature_wt),
));
}
#[rstest]
fn test_merge_no_commit_with_dirty_tree(mut repo: TestRepo) {
let feature_wt = repo.add_worktree_with_commit(
"feature",
"committed.txt",
"committed content",
"Add committed file",
);
fs::write(feature_wt.join("uncommitted.txt"), "uncommitted content").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--no-commit"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_no_commit_no_squash_no_remove_redundant(mut repo_with_feature_worktree: TestRepo) {
let repo = &mut repo_with_feature_worktree;
let feature_wt = &repo.worktrees["feature"];
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-commit", "--no-squash", "--no-remove"],
Some(feature_wt),
));
}
#[rstest]
fn test_merge_no_commits(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("no-commits");
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_no_commits_with_changes(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("no-commits-dirty");
fs::write(feature_wt.join("newfile.txt"), "new content").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_rebase_fast_forward(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("fast-forward-test");
fs::write(repo.root_path().join("main-update.txt"), "main content").unwrap();
repo.run_git(&["add", "main-update.txt"]);
repo.run_git(&["commit", "-m", "Update main"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_rebase_true_rebase(mut repo: TestRepo) {
let feature_wt = repo.add_worktree_with_commit(
"true-rebase-test",
"feature.txt",
"feature content",
"Add feature",
);
fs::write(repo.root_path().join("main-update.txt"), "main content").unwrap();
repo.run_git(&["add", "main-update.txt"]);
repo.run_git(&["commit", "-m", "Update main"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_no_rebase_when_already_rebased(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--no-rebase"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_no_rebase_when_not_rebased(mut repo: TestRepo) {
let feature_wt = repo.add_worktree_with_commit(
"not-rebased-test",
"feature.txt",
"feature content",
"Add feature",
);
fs::write(repo.root_path().join("main-update.txt"), "main content").unwrap();
repo.run_git(&["add", "main-update.txt"]);
repo.run_git(&["commit", "-m", "Update main"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--no-rebase"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_primary_on_different_branch(mut repo: TestRepo) {
repo.switch_primary_to("develop");
assert_eq!(repo.current_branch(), "develop");
let feature_wt = repo.add_worktree_with_commit(
"feature-from-develop",
"feature.txt",
"feature content",
"Add feature file",
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main"],
Some(&feature_wt)
));
assert_eq!(repo.current_branch(), "develop");
}
#[rstest]
fn test_merge_primary_on_different_branch_dirty(mut repo: TestRepo) {
fs::write(repo.root_path().join("file.txt"), "main version").unwrap();
repo.run_git(&["add", "file.txt"]);
repo.run_git(&["commit", "-m", "Update file on main"]);
let base_commit = String::from_utf8_lossy(
&repo
.git_command()
.args(["rev-parse", "HEAD~1"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&["switch", "-c", "develop", &base_commit]);
fs::write(repo.root_path().join("file.txt"), "develop local changes").unwrap();
let feature_wt = repo.add_worktree_with_commit(
"feature-dirty-primary",
"feature.txt",
"feature content",
"Add feature file",
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_race_condition_commit_after_push(mut repo_with_feature_worktree: TestRepo) {
let repo = &mut repo_with_feature_worktree;
let feature_wt = repo.worktrees["feature"].clone();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-remove"],
Some(&feature_wt)
));
fs::write(feature_wt.join("extra.txt"), "race condition commit").unwrap();
repo.run_git_in(&feature_wt, &["add", "extra.txt"]);
repo.run_git_in(
&feature_wt,
&["commit", "-m", "Add extra file (race condition)"],
);
repo.run_git(&["worktree", "remove", feature_wt.to_str().unwrap()]);
let output = repo
.git_command()
.args(["branch", "-d", "feature"])
.run()
.unwrap();
assert!(
!output.status.success(),
"git branch -d should fail when branch has unmerged commits"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not fully merged") || stderr.contains("not merged"),
"Error should mention branch is not fully merged, got: {}",
stderr
);
let output = repo
.git_command()
.args(["branch", "--list", "feature"])
.run()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("feature"),
"Branch should still exist after failed deletion"
);
}
#[rstest]
fn test_merge_to_non_default_target(repo: TestRepo) {
repo.run_git(&["switch", "main"]);
std::fs::write(repo.root_path().join("main-file.txt"), "main content").unwrap();
repo.run_git(&["add", "main-file.txt"]);
repo.run_git(&["commit", "-m", "Add main-specific file"]);
let base_commit = String::from_utf8_lossy(
&repo
.git_command()
.args(["rev-parse", "HEAD~1"])
.run()
.unwrap()
.stdout,
)
.trim()
.to_string();
repo.run_git(&["switch", "-c", "staging", &base_commit]);
std::fs::write(repo.root_path().join("staging-file.txt"), "staging content").unwrap();
repo.run_git(&["add", "staging-file.txt"]);
repo.run_git(&["commit", "-m", "Add staging-specific file"]);
repo.run_git(&["switch", "main"]);
let staging_wt = repo.root_path().parent().unwrap().join("repo.staging-wt");
repo.run_git(&["worktree", "add", staging_wt.to_str().unwrap(), "staging"]);
let feature_wt = repo
.root_path()
.parent()
.unwrap()
.join("repo.feature-for-staging");
repo.run_git(&[
"worktree",
"add",
feature_wt.to_str().unwrap(),
"-b",
"feature-for-staging",
&base_commit,
]);
std::fs::write(feature_wt.join("feature.txt"), "feature content").unwrap();
repo.run_git_in(&feature_wt, &["add", "feature.txt"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "Add feature for staging"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["staging"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_squash_with_working_tree_creates_backup(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("feature");
std::fs::write(feature_wt.join("file1.txt"), "content 1").unwrap();
repo.run_git_in(&feature_wt, &["add", "file1.txt"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feat: add file 1"]);
std::fs::write(feature_wt.join("file2.txt"), "content 2").unwrap();
repo.run_git_in(&feature_wt, &["add", "file2.txt"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feat: add file 2"]);
std::fs::write(feature_wt.join("file1.txt"), "updated content 1").unwrap();
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(repo, "merge", &["main"], Some(&feature_wt));
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'fix: update files'",
);
cmd
});
let output = repo
.git_command()
.args(["reflog", "show", "refs/wt-backup/feature"])
.run()
.unwrap();
let reflog = String::from_utf8_lossy(&output.stdout);
assert!(
reflog.contains("feature → main (squash)"),
"Expected backup in reflog, but reflog was: {}",
reflog
);
}
#[rstest]
fn test_merge_when_default_branch_missing_worktree(repo: TestRepo) {
repo.switch_primary_to("develop");
assert_cmd_snapshot!(make_snapshot_cmd(&repo, "merge", &[], None));
}
#[rstest]
fn test_merge_doesnt_set_receive_deny_current_branch(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
repo.run_git(&["config", "receive.denyCurrentBranch", "refuse"]);
let mut cmd = make_snapshot_cmd(&repo, "merge", &["main"], Some(&feature_wt));
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"Merge should succeed even with receive.denyCurrentBranch=refuse.\n\
stdout: {}\n\
stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let after = repo
.git_command()
.args(["config", "receive.denyCurrentBranch"])
.run()
.unwrap();
let after_value = String::from_utf8_lossy(&after.stdout).trim().to_string();
assert_eq!(
after_value, "refuse",
"receive.denyCurrentBranch should not be permanently modified by merge.\n\
Expected: \"refuse\"\n\
Got: {:?}",
after_value
);
}
#[rstest]
fn test_step_squash_with_no_hooks_flag(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
fs::create_dir_all(feature_wt.join(".config")).expect("Failed to create .config");
fs::write(
feature_wt.join(".config/wt.toml"),
"pre-commit = \"echo pre-commit check\"",
)
.expect("Failed to write wt.toml");
fs::write(feature_wt.join("file1.txt"), "content 1").expect("Failed to write file");
repo.run_git_in(&feature_wt, &["add", ".config", "file1.txt"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feat: add file 1"]);
fs::write(feature_wt.join("file2.txt"), "content 2").expect("Failed to write file");
repo.run_git_in(&feature_wt, &["add", "file2.txt"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feat: add file 2"]);
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], Some(&feature_wt));
cmd.arg("squash").args(["--no-hooks"]);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'squash: combined commits'",
);
cmd
});
}
#[rstest]
fn test_step_squash_with_stage_tracked_flag(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("file1.txt"), "content 1").expect("Failed to write file");
repo.run_git_in(&feature_wt, &["add", "file1.txt"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feat: add file 1"]);
fs::write(feature_wt.join("file2.txt"), "content 2").expect("Failed to write file");
repo.run_git_in(&feature_wt, &["add", "file2.txt"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feat: add file 2"]);
fs::write(feature_wt.join("file1.txt"), "updated content").expect("Failed to write file");
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], Some(&feature_wt));
cmd.arg("squash").args(["--stage=tracked"]);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'squash: combined commits'",
);
cmd
});
}
#[rstest]
fn test_step_squash_with_both_flags(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
fs::create_dir_all(feature_wt.join(".config")).expect("Failed to create .config");
fs::write(
feature_wt.join(".config/wt.toml"),
"pre-commit = \"echo pre-commit check\"",
)
.expect("Failed to write wt.toml");
fs::write(feature_wt.join("file1.txt"), "content 1").expect("Failed to write file");
repo.run_git_in(&feature_wt, &["add", ".config", "file1.txt"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feat: add file 1"]);
fs::write(feature_wt.join("file2.txt"), "content 2").expect("Failed to write file");
repo.run_git_in(&feature_wt, &["add", "file2.txt"]);
repo.run_git_in(&feature_wt, &["commit", "-m", "feat: add file 2"]);
fs::write(feature_wt.join("file1.txt"), "updated content").expect("Failed to write file");
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], Some(&feature_wt));
cmd.arg("squash").args(["--no-hooks", "--stage=tracked"]);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'squash: combined commits'",
);
cmd
});
}
#[rstest]
fn test_step_squash_no_commits(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["squash"],
Some(&feature_wt)
));
}
#[rstest]
fn test_step_squash_single_commit(mut repo: TestRepo) {
let feature_wt =
repo.add_worktree_with_commit("feature", "file1.txt", "content 1", "feat: single commit");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["squash"],
Some(&feature_wt)
));
}
#[rstest]
fn test_step_commit_with_no_hooks_flag(repo: TestRepo) {
fs::create_dir_all(repo.root_path().join(".config")).expect("Failed to create .config");
fs::write(
repo.root_path().join(".config/wt.toml"),
"pre-commit = \"echo pre-commit check\"",
)
.expect("Failed to write wt.toml");
fs::write(repo.root_path().join("file1.txt"), "content 1").expect("Failed to write file");
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], None);
cmd.arg("commit").args(["--no-hooks"]);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'feat: add file'",
);
cmd
});
}
#[rstest]
fn test_step_commit_with_stage_tracked_flag(repo: TestRepo) {
fs::write(repo.root_path().join("tracked.txt"), "initial").expect("Failed to write file");
repo.commit("add tracked file");
fs::write(repo.root_path().join("tracked.txt"), "modified").expect("Failed to write file");
fs::write(
repo.root_path().join("untracked.txt"),
"should not be staged",
)
.expect("Failed to write file");
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], None);
cmd.arg("commit").args(["--stage=tracked"]);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'fix: update tracked file'",
);
cmd
});
}
#[rstest]
fn test_step_commit_with_both_flags(repo: TestRepo) {
fs::create_dir_all(repo.root_path().join(".config")).expect("Failed to create .config");
fs::write(
repo.root_path().join(".config/wt.toml"),
"pre-commit = \"echo pre-commit check\"",
)
.expect("Failed to write wt.toml");
fs::write(repo.root_path().join("tracked.txt"), "initial").expect("Failed to write file");
repo.commit("add tracked file");
fs::write(repo.root_path().join("tracked.txt"), "modified").expect("Failed to write file");
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], None);
cmd.arg("commit").args(["--no-hooks", "--stage=tracked"]);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'fix: update file'",
);
cmd
});
}
#[rstest]
fn test_step_commit_nothing_to_commit(repo: TestRepo) {
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], None);
cmd.arg("commit").args(["--stage=none"]);
cmd
});
}
#[rstest]
fn test_step_commit_branch_flag(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("feature_file.txt"), "feature content")
.expect("Failed to write file");
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], None); cmd.arg("commit")
.args(["--branch", "feature", "--no-hooks"]);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'feat: add feature file'",
);
cmd
});
let log_output = {
let output = repo
.git_command()
.args(["log", "--oneline", "-1"])
.current_dir(&feature_wt)
.run()
.unwrap();
String::from_utf8_lossy(&output.stdout).trim().to_string()
};
assert!(
log_output.contains("feat: add feature file"),
"Commit should appear in feature worktree, got: {log_output}"
);
}
#[rstest]
fn test_step_commit_branch_flag_nonexistent(repo: TestRepo) {
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], None);
cmd.arg("commit").args(["--branch", "nonexistent"]);
cmd
});
}
#[rstest]
fn test_step_commit_detached_head(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
repo.detach_head_in_worktree("feature");
fs::write(feature_wt.join("detached_file.txt"), "detached content")
.expect("Failed to write file");
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], Some(&feature_wt));
cmd.arg("commit").args(["--no-hooks"]);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'chore: commit in detached state'",
);
cmd
});
let log_output = {
let output = repo
.git_command()
.args(["log", "--oneline", "-1"])
.current_dir(&feature_wt)
.run()
.unwrap();
String::from_utf8_lossy(&output.stdout).trim().to_string()
};
assert!(
log_output.contains("chore: commit in detached state"),
"Commit should appear in detached worktree, got: {log_output}"
);
}
#[rstest]
fn test_merge_error_uncommitted_changes_with_no_commit(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("dirty.txt"), "uncommitted content").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-commit", "--no-remove"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_error_conflicting_changes_in_target(mut repo_with_alternate_primary: TestRepo) {
let repo = &mut repo_with_alternate_primary;
let feature_wt = repo.add_worktree_with_commit(
"feature",
"shared.txt",
"feature content",
"Add shared.txt on feature",
);
let main_wt = repo.root_path().parent().unwrap().join("repo.main-wt");
fs::write(
main_wt.join("shared.txt"),
"conflicting uncommitted content",
)
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main"],
Some(&feature_wt)
));
}
#[rstest]
fn test_step_commit_show_prompt(repo: TestRepo) {
fs::write(repo.root_path().join("new_file.txt"), "new content").expect("Failed to write file");
repo.git_command().args(["add", "new_file.txt"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["commit", "--show-prompt"],
None
));
}
#[rstest]
fn test_step_commit_show_prompt_no_staged_changes(repo: TestRepo) {
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["commit", "--show-prompt"],
None
));
}
#[rstest]
fn test_step_squash_show_prompt(repo_with_multi_commit_feature: TestRepo) {
let repo = repo_with_multi_commit_feature;
let feature_wt = repo.worktree_path("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["squash", "--show-prompt"],
Some(feature_wt)
));
}
#[rstest]
fn test_step_rebase_with_merge_commit(mut repo: TestRepo) {
let feature_wt = repo.add_worktree_with_commit(
"feature-with-merge",
"feature.txt",
"feature content",
"Add feature",
);
fs::write(repo.root_path().join("main-update.txt"), "main content").unwrap();
repo.run_git(&["add", "main-update.txt"]);
repo.run_git(&["commit", "-m", "Update main"]);
let output = repo
.git_command()
.current_dir(&feature_wt)
.args(["merge", "main", "-m", "Merge main into feature"])
.run()
.unwrap();
assert!(
output.status.success(),
"git merge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_cmd_snapshot!({
let mut cmd = make_snapshot_cmd(&repo, "step", &[], Some(&feature_wt));
cmd.arg("rebase").args(["main"]);
cmd
});
}
#[rstest]
fn test_step_rebase_already_up_to_date(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["rebase"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_invalid_target(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["nonexistent-branch"],
Some(&feature_wt)
));
}
#[rstest]
fn test_step_rebase_invalid_target(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["rebase", "nonexistent-ref"],
Some(&feature_wt)
));
}
#[rstest]
fn test_step_rebase_accepts_tag(mut repo: TestRepo) {
repo.run_git(&["tag", "v1.0"]);
fs::write(repo.root_path().join("after-tag.txt"), "content").unwrap();
repo.run_git(&["add", "after-tag.txt"]);
repo.run_git(&["commit", "-m", "After tag"]);
let feature_wt = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["rebase", "v1.0"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_squash_ignored_with_no_commit(repo_with_multi_commit_feature: TestRepo) {
let repo = &repo_with_multi_commit_feature;
let feature_wt = &repo.worktrees["feature"];
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--squash", "--no-commit", "--no-remove"],
Some(feature_wt)
));
}
#[rstest]
fn test_merge_no_ff_basic(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--no-ff", "--no-remove"],
Some(&feature_wt)
));
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert_eq!(
parents.len(),
2,
"Merge commit should have exactly 2 parents"
);
let commit_msg = repo.git_output(&["log", "-1", "--format=%s", "main"]);
assert_eq!(commit_msg, "Merge branch 'feature' into main");
}
#[rstest]
fn test_merge_no_ff_multi_commit(repo_with_multi_commit_feature: TestRepo) {
let repo = &repo_with_multi_commit_feature;
let feature_wt = &repo.worktrees["feature"];
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-ff", "--no-squash", "--no-remove"],
Some(feature_wt)
));
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert_eq!(
parents.len(),
2,
"Merge commit should have exactly 2 parents"
);
let log = repo.git_output(&["log", "--oneline", "--graph", "main"]);
assert!(log.contains("Merge branch"), "Should contain merge commit");
}
#[rstest]
fn test_merge_no_ff_with_squash(repo_with_multi_commit_feature: TestRepo) {
let repo = &repo_with_multi_commit_feature;
let feature_wt = &repo.worktrees["feature"];
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-ff", "--no-remove"],
Some(feature_wt)
));
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert_eq!(
parents.len(),
2,
"Merge commit should have exactly 2 parents"
);
}
#[rstest]
fn test_merge_no_ff_from_config(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
fs::write(repo.test_config_path(), "[merge]\nff = false\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--no-remove"],
Some(&feature_wt)
));
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert_eq!(
parents.len(),
2,
"Config-driven --no-ff should create merge commit"
);
}
#[rstest]
fn test_merge_ff_flag_overrides_config(merge_scenario: (TestRepo, PathBuf)) {
let (repo, feature_wt) = merge_scenario;
fs::write(repo.test_config_path(), "[merge]\nff = false\n").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--ff", "--no-remove"],
Some(&feature_wt)
));
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert_eq!(
parents.len(),
1,
"--ff should override config and fast-forward"
);
}
#[rstest]
fn test_merge_no_ff_with_rebase(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let main_wt = repo.root_path().to_path_buf();
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(&feature_wt, "feature.txt", "feature content", "Add feature");
repo.commit_in_worktree(&main_wt, "main.txt", "main content", "Advance main");
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-ff", "--no-squash", "--no-remove"],
Some(&feature_wt)
));
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert_eq!(parents.len(), 2, "Should create merge commit after rebase");
}
#[rstest]
fn test_merge_no_ff_already_up_to_date(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let feature_wt = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-ff", "--no-remove"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_no_ff_diverged_no_rebase(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let main_wt = repo.root_path().to_path_buf();
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(&feature_wt, "feature.txt", "feature content", "Add feature");
repo.commit_in_worktree(&main_wt, "main.txt", "main content", "Advance main");
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-ff", "--no-rebase", "--no-remove"],
Some(&feature_wt)
));
}
#[rstest]
fn test_merge_no_ff_syncs_target_worktree(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let main_wt = repo.root_path().to_path_buf();
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(
&feature_wt,
"feature.txt",
"feature content",
"Add feature file",
);
let output = repo
.wt_command()
.args(["merge", "main", "--no-ff", "--no-remove"])
.current_dir(&feature_wt)
.output()
.unwrap();
assert!(
output.status.success(),
"merge should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
main_wt.join("feature.txt").exists(),
"Target worktree should contain the merged file after read-tree"
);
let commit_msg = repo.git_output(&["log", "-1", "--format=%s", "main"]);
assert_eq!(commit_msg, "Merge branch 'feature' into main");
let main_tip = repo.git_output(&["rev-parse", "main"]);
let wt_head_output = repo
.git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&main_wt)
.run()
.unwrap();
let wt_head = String::from_utf8_lossy(&wt_head_output.stdout)
.trim()
.to_string();
assert_eq!(
main_tip, wt_head,
"Target worktree HEAD should match main after read-tree"
);
}
#[rstest]
fn test_merge_no_ff_dirty_target_autostash(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let main_wt = repo.root_path().to_path_buf();
fs::write(main_wt.join("notes.txt"), "temporary notes").unwrap();
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(&feature_wt, "feature.txt", "feature content", "Add feature");
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-ff", "--no-remove"],
Some(&feature_wt)
));
let notes = fs::read_to_string(main_wt.join("notes.txt")).unwrap();
assert_eq!(
notes, "temporary notes",
"Autostash should restore dirty file"
);
let stash_list = repo.git_command().args(["stash", "list"]).run().unwrap();
assert!(
String::from_utf8_lossy(&stash_list.stdout)
.trim()
.is_empty(),
"Autostash should clean up after itself"
);
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert_eq!(parents.len(), 2, "Should create merge commit");
}
#[rstest]
fn test_merge_no_ff_dirty_target_conflict(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let main_wt = repo.root_path().to_path_buf();
fs::write(main_wt.join("conflict.txt"), "old content").unwrap();
let feature_wt =
repo.add_worktree_with_commit("feature", "conflict.txt", "new content", "Add conflict");
assert_cmd_snapshot!(make_snapshot_cmd(
repo,
"merge",
&["main", "--no-ff", "--no-remove"],
Some(&feature_wt)
));
let contents = fs::read_to_string(main_wt.join("conflict.txt")).unwrap();
assert_eq!(
contents, "old content",
"Target dirty file should be untouched"
);
let stash_list = repo.git_command().args(["stash", "list"]).run().unwrap();
assert!(
String::from_utf8_lossy(&stash_list.stdout)
.trim()
.is_empty(),
"No stash should be created on conflict"
);
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert!(
parents.len() < 2,
"No merge commit should be created on conflict"
);
}
#[rstest]
fn test_merge_no_ff_sync_failure_warns(mut repo_with_main_worktree: TestRepo) {
let repo = &mut repo_with_main_worktree;
let main_wt = repo.root_path().to_path_buf();
let feature_wt = repo.add_worktree("feature");
repo.commit_in_worktree(&feature_wt, "feature.txt", "feature content", "Add feature");
let index_lock = main_wt.join(".git/index.lock");
fs::write(&index_lock, "").unwrap();
let output = repo
.wt_command()
.args(["merge", "main", "--no-ff", "--no-remove"])
.current_dir(&feature_wt)
.output()
.unwrap();
assert!(
output.status.success(),
"merge should succeed despite sync failure: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Failed to sync target worktree"),
"should warn about sync failure: {stderr}"
);
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert_eq!(
parents.len(),
2,
"Should create merge commit despite sync failure"
);
let _ = fs::remove_file(&index_lock);
}
#[rstest]
fn test_merge_no_ff_target_without_worktree(repo: TestRepo) {
repo.switch_primary_to("develop");
repo.commit_in_worktree(
repo.root_path(),
"feature.txt",
"feature content",
"Add feature",
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--no-ff"],
None
));
let parent_count = repo.git_output(&["cat-file", "-p", "main"]);
let parents: Vec<&str> = parent_count
.lines()
.filter(|l| l.starts_with("parent "))
.collect();
assert_eq!(
parents.len(),
2,
"Should create merge commit even without target worktree"
);
}
#[rstest]
fn test_merge_post_merge_pipeline_serial_ordering(mut repo: TestRepo) {
let config_dir = repo.root_path().join(".config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("wt.toml"),
r#"post-merge = [
"echo STEP_ONE_DONE > step_one_marker.txt",
"cat step_one_marker.txt > step_two_saw_one.txt"
]
"#,
)
.unwrap();
repo.commit("Add pipeline config");
let feature_wt = repo.add_feature();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"merge",
&["main", "--yes"],
Some(&feature_wt)
));
let marker_file = repo.root_path().join("step_two_saw_one.txt");
wait_for_file_content(&marker_file);
let content = fs::read_to_string(&marker_file).unwrap();
assert!(
content.contains("STEP_ONE_DONE"),
"Step 2 should see step 1's output (serial pipeline), got: {content}"
);
}
#[rstest]
fn test_merge_json(repo: TestRepo) {
let (repo, feature_wt) = merge_scenario(repo);
let output = repo
.wt_command()
.args(["merge", "--format=json", "--yes", "--no-hooks"])
.current_dir(&feature_wt)
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_snapshot!(String::from_utf8_lossy(&output.stdout), @r#"
{
"branch": "feature",
"committed": false,
"rebased": false,
"removed": true,
"squashed": false,
"target": "main"
}
"#);
}