use crate::common::{TestRepo, make_snapshot_cmd, repo, setup_snapshot_settings, wt_command};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use worktrunk::git::Repository;
use worktrunk::shell_exec::Cmd;
fn branch_name(repo: &TestRepo, dir: &std::path::Path) -> String {
let output = repo
.git_command()
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(dir)
.run()
.unwrap();
assert!(
output.status.success(),
"git rev-parse failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[rstest]
fn test_promote_from_worktree(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let feature_path = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote"],
Some(&feature_path),
));
assert_eq!(
branch_name(&repo, repo.root_path()),
"feature",
"main worktree should now have feature"
);
assert_eq!(
branch_name(&repo, &feature_path),
"main",
"other worktree should now have main"
);
}
#[rstest]
fn test_promote_with_branch_argument(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let feature_path = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "feature"],
Some(repo.root_path()),
));
assert_eq!(
branch_name(&repo, repo.root_path()),
"feature",
"main worktree should now have feature"
);
assert_eq!(
branch_name(&repo, &feature_path),
"main",
"other worktree should now have main"
);
}
#[rstest]
fn test_promote_restore(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let feature_path = repo.add_worktree("feature");
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"first promote failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(branch_name(&repo, repo.root_path()), "feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "main"],
Some(repo.root_path()),
));
assert_eq!(
branch_name(&repo, repo.root_path()),
"main",
"main worktree should have main again"
);
assert_eq!(
branch_name(&repo, &feature_path),
"feature",
"other worktree should have feature again"
);
}
#[rstest]
fn test_promote_already_in_main(repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "main"],
Some(repo.root_path()),
));
}
#[rstest]
fn test_promote_auto_restore(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let feature_path = repo.add_worktree("feature");
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"first promote failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(branch_name(&repo, repo.root_path()), "feature");
assert_eq!(branch_name(&repo, &feature_path), "main");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote"],
Some(repo.root_path()),
));
assert_eq!(
branch_name(&repo, repo.root_path()),
"main",
"main worktree should have main again"
);
assert_eq!(
branch_name(&repo, &feature_path),
"feature",
"other worktree should have feature again"
);
}
#[rstest]
fn test_promote_no_arg_from_main(repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote"],
Some(repo.root_path()),
));
}
#[rstest]
fn test_promote_branch_not_in_worktree(repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
repo.run_git(&["branch", "orphan"]);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "orphan"],
Some(repo.root_path()),
));
}
#[rstest]
fn test_promote_dirty_main(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let _feature_path = repo.add_worktree("feature");
fs::write(repo.root_path().join("dirty.txt"), "uncommitted").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "feature"],
Some(repo.root_path()),
));
}
#[rstest]
fn test_promote_dirty_target(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let feature_path = repo.add_worktree("feature");
fs::write(feature_path.join("dirty.txt"), "uncommitted").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "feature"],
Some(repo.root_path()),
));
}
#[rstest]
fn test_promote_shows_mismatch_in_list(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let _feature_path = repo.add_worktree("feature");
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"promote failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"list",
&[],
Some(repo.root_path()),
));
}
#[test]
fn test_promote_bare_repo_no_worktrees() {
let test = TestRepo::bare();
let output = test
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success());
assert!(
stderr.contains("No worktrees found"),
"Expected no worktrees error, got: {stderr}"
);
}
#[test]
fn test_promote_bare_repo_with_worktrees() {
let temp_dir = tempfile::tempdir().unwrap();
let bare_repo = temp_dir.path().join("bare.git");
let worktree_path = temp_dir.path().join("worktree");
let temp_clone = temp_dir.path().join("temp");
Cmd::new("git")
.args([
"init",
"--bare",
"--initial-branch=main",
bare_repo.to_str().unwrap(),
])
.run()
.unwrap();
Cmd::new("git")
.args([
"clone",
bare_repo.to_str().unwrap(),
temp_clone.to_str().unwrap(),
])
.run()
.unwrap();
let clone_repo = Repository::at(&temp_clone).unwrap();
clone_repo
.run_command(&["config", "user.email", "test@test.com"])
.unwrap();
clone_repo
.run_command(&["config", "user.name", "Test"])
.unwrap();
clone_repo
.run_command(&["commit", "--allow-empty", "-m", "init"])
.unwrap();
clone_repo.run_command(&["push", "origin", "main"]).unwrap();
let bare_repo_handle = Repository::at(&bare_repo).unwrap();
bare_repo_handle
.run_command(&["worktree", "add", worktree_path.to_str().unwrap(), "main"])
.unwrap();
let output = wt_command()
.args(["step", "promote", "feature"])
.current_dir(&bare_repo)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success());
assert!(
stderr.contains("bare repositories"),
"Expected bare repo error, got: {stderr}"
);
}
#[rstest]
fn test_promote_detached_head_main(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let _feature_path = repo.add_worktree("feature");
let sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.current_dir(repo.root_path())
.run()
.unwrap();
let sha = String::from_utf8_lossy(&sha.stdout).trim().to_string();
repo.git_command()
.args(["checkout", "--detach", &sha])
.current_dir(repo.root_path())
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "feature"],
Some(repo.root_path()),
));
}
fn commit_gitignore(repo: &TestRepo, dir: &std::path::Path, content: &str) {
fs::write(dir.join(".gitignore"), content).unwrap();
repo.run_git_in(dir, &["add", ".gitignore"]);
repo.run_git_in(dir, &["commit", "-m", "add gitignore"]);
}
#[rstest]
fn test_promote_swap_bidirectional(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n*.log\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::create_dir_all(repo.root_path().join("build")).unwrap();
fs::write(repo.root_path().join("build/main-artifact"), "main build").unwrap();
fs::write(repo.root_path().join("app.log"), "main log").unwrap();
fs::create_dir_all(feature_path.join("build")).unwrap();
fs::write(feature_path.join("build/feature-artifact"), "feature build").unwrap();
fs::write(feature_path.join("debug.log"), "feature log").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "feature"],
Some(repo.root_path()),
));
assert_eq!(
fs::read_to_string(repo.root_path().join("build/feature-artifact")).unwrap(),
"feature build"
);
assert_eq!(
fs::read_to_string(repo.root_path().join("debug.log")).unwrap(),
"feature log"
);
assert!(!repo.root_path().join("build/main-artifact").exists());
assert!(!repo.root_path().join("app.log").exists());
assert_eq!(
fs::read_to_string(feature_path.join("build/main-artifact")).unwrap(),
"main build"
);
assert_eq!(
fs::read_to_string(feature_path.join("app.log")).unwrap(),
"main log"
);
assert!(!feature_path.join("build/feature-artifact").exists());
assert!(!feature_path.join("debug.log").exists());
}
#[rstest]
fn test_promote_swap_only_main_has_ignored(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n*.log\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::create_dir_all(repo.root_path().join("build")).unwrap();
fs::write(repo.root_path().join("build/artifact"), "main artifact").unwrap();
fs::write(repo.root_path().join("app.log"), "main log").unwrap();
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!repo.root_path().join("build").exists());
assert!(!repo.root_path().join("app.log").exists());
assert_eq!(
fs::read_to_string(feature_path.join("build/artifact")).unwrap(),
"main artifact"
);
assert_eq!(
fs::read_to_string(feature_path.join("app.log")).unwrap(),
"main log"
);
}
#[rstest]
fn test_promote_swap_only_feature_has_ignored(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::create_dir_all(feature_path.join("build")).unwrap();
fs::write(feature_path.join("build/artifact"), "feature artifact").unwrap();
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(repo.root_path().join("build/artifact")).unwrap(),
"feature artifact"
);
assert!(!feature_path.join("build").exists());
}
#[rstest]
fn test_promote_swap_no_ignored_files(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let _feature_path = repo.add_worktree("feature");
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "feature"],
Some(repo.root_path()),
));
}
#[rstest]
fn test_promote_swap_nested_directories(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::create_dir_all(repo.root_path().join("build/debug/x86/obj")).unwrap();
fs::write(
repo.root_path().join("build/debug/x86/obj/main.o"),
"main object",
)
.unwrap();
fs::write(repo.root_path().join("build/debug/main.bin"), "main binary").unwrap();
fs::create_dir_all(feature_path.join("build/release/arm64")).unwrap();
fs::write(
feature_path.join("build/release/arm64/feature.bin"),
"feature binary",
)
.unwrap();
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(repo.root_path().join("build/release/arm64/feature.bin")).unwrap(),
"feature binary"
);
assert!(!repo.root_path().join("build/debug").exists());
assert_eq!(
fs::read_to_string(feature_path.join("build/debug/x86/obj/main.o")).unwrap(),
"main object"
);
assert_eq!(
fs::read_to_string(feature_path.join("build/debug/main.bin")).unwrap(),
"main binary"
);
assert!(!feature_path.join("build/release").exists());
}
#[rstest]
fn test_promote_swap_same_filename_different_content(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::create_dir_all(repo.root_path().join("build")).unwrap();
fs::write(repo.root_path().join("build/output.bin"), "main output").unwrap();
fs::create_dir_all(feature_path.join("build")).unwrap();
fs::write(feature_path.join("build/output.bin"), "feature output").unwrap();
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(repo.root_path().join("build/output.bin")).unwrap(),
"feature output",
"main worktree should have feature's content"
);
assert_eq!(
fs::read_to_string(feature_path.join("build/output.bin")).unwrap(),
"main output",
"feature worktree should have main's content"
);
}
#[rstest]
fn test_promote_swap_roundtrip(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n*.log\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::create_dir_all(repo.root_path().join("build")).unwrap();
fs::write(repo.root_path().join("build/artifact"), "main build").unwrap();
fs::write(repo.root_path().join("app.log"), "main log").unwrap();
fs::create_dir_all(feature_path.join("build")).unwrap();
fs::write(feature_path.join("build/artifact"), "feature build").unwrap();
fs::write(feature_path.join("debug.log"), "feature log").unwrap();
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let output = repo
.wt_command()
.args(["step", "promote", "main"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(branch_name(&repo, repo.root_path()), "main");
assert_eq!(branch_name(&repo, &feature_path), "feature");
assert_eq!(
fs::read_to_string(repo.root_path().join("build/artifact")).unwrap(),
"main build",
"main worktree should have its original build artifact back"
);
assert_eq!(
fs::read_to_string(repo.root_path().join("app.log")).unwrap(),
"main log"
);
assert_eq!(
fs::read_to_string(feature_path.join("build/artifact")).unwrap(),
"feature build",
"feature worktree should have its original build artifact back"
);
assert_eq!(
fs::read_to_string(feature_path.join("debug.log")).unwrap(),
"feature log"
);
}
#[rstest]
fn test_promote_swap_respects_worktreeinclude(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n*.log\n.env\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::write(repo.root_path().join(".worktreeinclude"), "build/\n").unwrap();
repo.run_git(&["add", ".worktreeinclude"]);
repo.run_git(&["commit", "-m", "add worktreeinclude"]);
fs::write(feature_path.join(".worktreeinclude"), "build/\n").unwrap();
repo.run_git_in(&feature_path, &["add", ".worktreeinclude"]);
repo.run_git_in(&feature_path, &["commit", "-m", "add worktreeinclude"]);
fs::create_dir_all(repo.root_path().join("build")).unwrap();
fs::write(repo.root_path().join("build/main-bin"), "main binary").unwrap();
fs::write(repo.root_path().join(".env"), "MAIN_SECRET=1").unwrap();
fs::write(repo.root_path().join("app.log"), "main log").unwrap();
fs::create_dir_all(feature_path.join("build")).unwrap();
fs::write(feature_path.join("build/feature-bin"), "feature binary").unwrap();
fs::write(feature_path.join(".env"), "FEATURE_SECRET=1").unwrap();
fs::write(feature_path.join("debug.log"), "feature log").unwrap();
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(repo.root_path().join("build/feature-bin")).unwrap(),
"feature binary"
);
assert_eq!(
fs::read_to_string(feature_path.join("build/main-bin")).unwrap(),
"main binary"
);
assert_eq!(
fs::read_to_string(repo.root_path().join(".env")).unwrap(),
"MAIN_SECRET=1",
".env should stay in place"
);
assert_eq!(
fs::read_to_string(repo.root_path().join("app.log")).unwrap(),
"main log",
"app.log should stay in place"
);
assert_eq!(
fs::read_to_string(feature_path.join(".env")).unwrap(),
"FEATURE_SECRET=1",
".env should stay in place"
);
assert_eq!(
fs::read_to_string(feature_path.join("debug.log")).unwrap(),
"feature log",
"debug.log should stay in place"
);
}
#[rstest]
fn test_promote_swap_no_staging_leftover(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::create_dir_all(repo.root_path().join("build")).unwrap();
fs::write(repo.root_path().join("build/artifact"), "data").unwrap();
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let git_dir = repo.root_path().join(".git");
assert!(
!git_dir.join("wt/staging/promote").exists(),
"staging directory should be cleaned up after promote"
);
}
#[rstest]
fn test_promote_swap_does_not_touch_tracked_files(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::write(repo.root_path().join("src.txt"), "main source").unwrap();
repo.run_git(&["add", "src.txt"]);
repo.run_git(&["commit", "-m", "add source"]);
fs::write(feature_path.join("feat.txt"), "feature source").unwrap();
repo.run_git_in(&feature_path, &["add", "feat.txt"]);
repo.run_git_in(&feature_path, &["commit", "-m", "add feat source"]);
fs::create_dir_all(repo.root_path().join("build")).unwrap();
fs::write(repo.root_path().join("build/main.o"), "main obj").unwrap();
fs::create_dir_all(feature_path.join("build")).unwrap();
fs::write(feature_path.join("build/feat.o"), "feat obj").unwrap();
let output = repo
.wt_command()
.args(["step", "promote", "feature"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
fs::read_to_string(repo.root_path().join("feat.txt")).unwrap(),
"feature source"
);
assert_eq!(
fs::read_to_string(feature_path.join("src.txt")).unwrap(),
"main source"
);
assert_eq!(
fs::read_to_string(repo.root_path().join("build/feat.o")).unwrap(),
"feat obj"
);
assert_eq!(
fs::read_to_string(feature_path.join("build/main.o")).unwrap(),
"main obj"
);
}
#[rstest]
fn test_promote_stale_staging_blocks_with_guidance(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let feature_path = repo.add_worktree("feature");
let gitignore = "build/\n";
commit_gitignore(&repo, repo.root_path(), gitignore);
commit_gitignore(&repo, &feature_path, gitignore);
fs::create_dir_all(repo.root_path().join("build")).unwrap();
fs::write(repo.root_path().join("build/artifact"), "main build").unwrap();
let git_dir = repo.root_path().join(".git");
let staging_dir = git_dir.join("wt/staging/promote");
fs::create_dir_all(staging_dir.join("a")).unwrap();
fs::write(staging_dir.join("a/leftover"), "leftover data").unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote", "feature"],
None
));
assert!(staging_dir.exists(), "staging dir should be preserved");
assert_eq!(
fs::read_to_string(staging_dir.join("a/leftover")).unwrap(),
"leftover data"
);
}
#[rstest]
fn test_promote_detached_head_linked(mut repo: TestRepo) {
let _settings_guard = setup_snapshot_settings(&repo).bind_to_scope();
let feature_path = repo.add_worktree("feature");
let sha = repo
.git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&feature_path)
.run()
.unwrap();
let sha = String::from_utf8_lossy(&sha.stdout).trim().to_string();
repo.git_command()
.args(["checkout", "--detach", &sha])
.current_dir(&feature_path)
.run()
.unwrap();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["promote"],
Some(&feature_path),
));
}