use std::path::{Path, PathBuf};
use std::process::Command;
use assert_cmd::Command as TestCommand;
use predicates::str::contains;
use tempfile::TempDir;
fn limb() -> TestCommand {
TestCommand::cargo_bin("limb").expect("bin built")
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {} failed", args.join(" "));
}
fn init_repo(dir: &Path) {
git(dir, &["init", "-q", "-b", "main"]);
git(dir, &["config", "user.email", "test@example.com"]);
git(dir, &["config", "user.name", "test"]);
git(dir, &["config", "commit.gpgsign", "false"]);
git(dir, &["config", "core.hooksPath", "/dev/null"]);
git(dir, &["commit", "--allow-empty", "-q", "-m", "chore: init"]);
}
fn sandbox() -> (TempDir, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).expect("mkdir repo");
init_repo(&repo);
(tmp, repo)
}
#[test]
fn help_works() {
limb()
.arg("--help")
.assert()
.success()
.stdout(contains("git worktree"))
.stdout(contains("Commands:"));
}
#[test]
fn version_works() {
limb()
.arg("--version")
.assert()
.success()
.stdout(contains("limb"));
}
#[test]
fn init_zsh_without_git_repo() {
let tmp = tempfile::tempdir().expect("tempdir");
limb()
.current_dir(tmp.path())
.args(["init", "zsh"])
.assert()
.success()
.stdout(contains("gw() {"))
.stdout(contains("gwa() {"))
.stdout(contains("gwp() {"));
}
#[test]
fn init_custom_prefix() {
let tmp = tempfile::tempdir().expect("tempdir");
limb()
.current_dir(tmp.path())
.args(["init", "zsh", "--prefix", "lm"])
.assert()
.success()
.stdout(contains("lm() {"))
.stdout(contains("lma() {"));
}
#[test]
fn completions_emits_shell_script() {
let tmp = tempfile::tempdir().expect("tempdir");
limb()
.current_dir(tmp.path())
.args(["completions", "bash"])
.assert()
.success()
.stdout(contains("_limb"));
}
#[test]
fn list_empty_repo_shows_main() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.stdout(contains("NAME"))
.stdout(contains("main"));
}
#[test]
fn list_json_is_valid() {
let (_tmp, repo) = sandbox();
let out = limb()
.current_dir(&repo)
.args(["--json", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
assert!(parsed.is_array());
assert_eq!(parsed.as_array().unwrap().len(), 1);
}
#[test]
fn add_creates_worktree() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-x"])
.assert()
.success();
limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.stdout(contains("feat-x"));
}
#[test]
fn cd_prints_path() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-y"])
.assert()
.success();
let out = limb()
.current_dir(&repo)
.args(["cd", "feat-y"])
.assert()
.success()
.get_output()
.stdout
.clone();
let path = String::from_utf8(out).unwrap();
assert!(
path.contains("feat-y"),
"expected path to contain feat-y, got: {path}"
);
}
#[test]
fn cd_unknown_fails() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["cd", "nope"])
.assert()
.failure()
.stderr(contains("no worktree named"));
}
#[test]
fn remove_deletes_worktree() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-z"])
.assert()
.success();
limb()
.current_dir(&repo)
.args(["remove", "feat-z"])
.assert()
.success();
let out = limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(!String::from_utf8_lossy(&out).contains("feat-z"));
}
#[test]
fn doctor_works_in_repo() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.arg("doctor")
.assert()
.success()
.stdout(contains("repo:"));
}
#[test]
fn doctor_fails_outside_repo() {
let tmp = tempfile::tempdir().expect("tempdir");
limb()
.current_dir(tmp.path())
.arg("doctor")
.assert()
.failure()
.stderr(contains("not a git repository"));
}
#[test]
fn status_returns_rows() {
let (_tmp, repo) = sandbox();
let out = limb()
.current_dir(&repo)
.args(["--json", "status"])
.assert()
.success()
.get_output()
.stdout
.clone();
let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
assert!(parsed.is_array());
let rows = parsed.as_array().unwrap();
assert_eq!(rows.len(), 1);
assert!(rows[0].get("dirty_files").is_some());
}
#[test]
fn config_emits_json() {
let (_tmp, repo) = sandbox();
let out = limb()
.current_dir(&repo)
.arg("config")
.assert()
.success()
.get_output()
.stdout
.clone();
let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
assert!(parsed.get("repo").is_some());
}
#[test]
fn list_all_without_config_errors_actionable() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
std::fs::create_dir_all(&home).unwrap();
limb()
.env("HOME", &home)
.env_remove("XDG_CONFIG_HOME")
.current_dir(tmp.path())
.args(["list", "--all"])
.assert()
.failure()
.stderr(contains("projects.roots"));
}
#[test]
fn list_all_with_config_enumerates_repos() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("root");
std::fs::create_dir_all(&root).unwrap();
let repo_a = root.join("alpha");
let repo_b = root.join("beta");
std::fs::create_dir_all(&repo_a).unwrap();
std::fs::create_dir_all(&repo_b).unwrap();
init_repo(&repo_a);
init_repo(&repo_b);
let cfg_path = tmp.path().join("config.toml");
let cfg = format!("[projects]\nroots = ['{}']\n", root.to_string_lossy());
std::fs::write(&cfg_path, cfg).unwrap();
let out = limb()
.current_dir(tmp.path())
.args([
"--config",
cfg_path.to_str().unwrap(),
"--json",
"list",
"--all",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let parsed: serde_json::Value = serde_json::from_slice(&out).expect("json parses");
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 2);
let repos: Vec<&str> = arr.iter().map(|r| r["repo"].as_str().unwrap()).collect();
assert!(repos.contains(&"alpha"));
assert!(repos.contains(&"beta"));
}
#[test]
fn update_accepts_both_flags_exclusive() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["update", "--fetch-only", "--ff-only"])
.assert()
.failure()
.stderr(contains("cannot be used with"));
}
#[test]
fn setup_without_config_fails_actionable() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.arg("setup")
.assert()
.failure()
.stderr(contains(".limb.toml"));
}
#[cfg(unix)]
#[test]
fn setup_symlinks_shared_files() {
let (_tmp, repo) = sandbox();
let shared = repo.join(".shared");
std::fs::create_dir_all(&shared).unwrap();
std::fs::write(shared.join("mise.toml"), "[tools]\nrust = \"1.94\"\n").unwrap();
std::fs::write(
repo.join(".limb.toml"),
"[worktrees]\nshared = [\"mise.toml\"]\n",
)
.unwrap();
limb()
.current_dir(&repo)
.arg("setup")
.assert()
.success()
.stderr(contains("link"));
let link = repo.join("mise.toml");
assert!(link.exists(), "symlink should exist");
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
}
#[cfg(unix)]
#[test]
fn setup_dry_run_does_not_create_files() {
let (_tmp, repo) = sandbox();
let shared = repo.join(".shared");
std::fs::create_dir_all(&shared).unwrap();
std::fs::write(shared.join("env"), "KEY=value\n").unwrap();
std::fs::write(repo.join(".limb.toml"), "[worktrees]\nshared = [\"env\"]\n").unwrap();
limb()
.current_dir(&repo)
.args(["setup", "--dry-run"])
.assert()
.success()
.stderr(contains("would"));
assert!(!repo.join("env").exists());
}
#[test]
fn add_with_template_interpolates_name() {
let (_tmp, repo) = sandbox();
std::fs::write(
repo.join(".limb.toml"),
"[templates.feature]\nname_pattern = \"feat-{slug}\"\n",
)
.unwrap();
limb()
.current_dir(&repo)
.args(["add", "--template", "feature", "auth"])
.assert()
.success();
let out = limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.get_output()
.stdout
.clone();
let s = String::from_utf8_lossy(&out);
assert!(s.contains("feat-auth"), "expected feat-auth; got:\n{s}");
}
#[test]
fn add_unknown_template_errors_with_list() {
let (_tmp, repo) = sandbox();
std::fs::write(
repo.join(".limb.toml"),
"[templates.feature]\nname_pattern = \"feat-{slug}\"\n",
)
.unwrap();
limb()
.current_dir(&repo)
.args(["add", "--template", "missing", "x"])
.assert()
.failure()
.stderr(contains("unknown template"))
.stderr(contains("feature"));
}
#[cfg(unix)]
#[test]
fn hooks_pre_add_runs_and_can_abort() {
let (_tmp, repo) = sandbox();
let scripts = repo.join("scripts");
std::fs::create_dir_all(&scripts).unwrap();
let hook = scripts.join("pre_add.sh");
std::fs::write(&hook, "#!/usr/bin/env sh\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
std::fs::write(
repo.join(".limb.toml"),
"[hooks]\npre_add = \"scripts/pre_add.sh\"\n",
)
.unwrap();
limb()
.current_dir(&repo)
.args(["add", "feat-blocked"])
.assert()
.failure()
.stderr(contains("pre_add"));
}
#[cfg(unix)]
#[test]
fn hooks_post_add_runs_with_env() {
let (_tmp, repo) = sandbox();
let scripts = repo.join("scripts");
std::fs::create_dir_all(&scripts).unwrap();
let hook = scripts.join("post_add.sh");
let marker = repo.join("marker.txt");
let script = format!(
"#!/usr/bin/env sh\necho \"$LIMB_WORKTREE_NAME:$LIMB_WORKTREE_PATH\" > {}\n",
marker.display()
);
std::fs::write(&hook, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
std::fs::write(
repo.join(".limb.toml"),
"[hooks]\npost_add = \"scripts/post_add.sh\"\n",
)
.unwrap();
limb()
.current_dir(&repo)
.args(["add", "feat-q"])
.assert()
.success();
let marker_text = std::fs::read_to_string(&marker).expect("marker written");
assert!(marker_text.contains("feat-q:"));
assert!(marker_text.contains("feat-q\n") || marker_text.contains("feat-q"));
}
#[cfg(unix)]
#[test]
fn hooks_post_add_failure_does_not_abort_worktree() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, repo) = sandbox();
let scripts = repo.join("scripts");
std::fs::create_dir_all(&scripts).unwrap();
let hook = scripts.join("post_add.sh");
std::fs::write(&hook, "#!/usr/bin/env sh\nexit 7\n").unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::write(
repo.join(".limb.toml"),
"[hooks]\npost_add = \"scripts/post_add.sh\"\n",
)
.unwrap();
limb()
.current_dir(&repo)
.args(["add", "feat-p"])
.assert()
.success()
.stderr(contains("post_add"));
limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.stdout(contains("feat-p"));
}
#[test]
fn doctor_reports_missing_projects_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
init_repo(&repo);
let cfg_path = tmp.path().join("config.toml");
let cfg = format!(
"[projects]\nroots = ['{}']\n",
tmp.path().join("does-not-exist").to_string_lossy()
);
std::fs::write(&cfg_path, cfg).unwrap();
limb()
.current_dir(&repo)
.args(["--config", cfg_path.to_str().unwrap(), "doctor"])
.assert()
.failure()
.stderr(contains("does not exist"))
.stderr(contains("try:"));
}
#[cfg(unix)]
#[test]
fn hook_parent_dir_traversal_rejected() {
let (_tmp, repo) = sandbox();
std::fs::write(
repo.join(".limb.toml"),
"[hooks]\npre_add = \"../escape.sh\"\n",
)
.unwrap();
limb()
.current_dir(&repo)
.args(["add", "feat-esc"])
.assert()
.failure()
.stderr(contains("'..'"));
}
#[test]
fn lock_then_unlock_roundtrip() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-lk"])
.assert()
.success();
limb()
.current_dir(&repo)
.args(["lock", "feat-lk", "--reason", "demo"])
.assert()
.success()
.stderr(contains("locked"));
limb()
.current_dir(&repo)
.args(["unlock", "feat-lk"])
.assert()
.success()
.stderr(contains("unlocked"));
}
#[test]
fn repair_succeeds_on_healthy_repo() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.arg("repair")
.assert()
.success()
.stderr(contains("repaired"));
}
#[test]
fn prune_dry_run_is_noop() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["prune", "--dry-run"])
.assert()
.success();
}
#[test]
fn rename_moves_worktree() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-old"])
.assert()
.success();
let parent = repo.parent().unwrap();
assert!(parent.join("feat-old").exists());
limb()
.current_dir(&repo)
.args(["rename", "feat-old", "feat-new"])
.assert()
.success()
.stderr(contains("feat-new"));
assert!(!parent.join("feat-old").exists(), "old dir should be gone");
assert!(parent.join("feat-new").exists(), "new dir should exist");
let out = limb()
.current_dir(&repo)
.args(["--json", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
let names: Vec<String> = json
.as_array()
.unwrap()
.iter()
.filter_map(|row| row["name"].as_str().map(String::from))
.collect();
assert!(names.contains(&"feat-new".to_string()));
assert!(!names.contains(&"feat-old".to_string()));
}
#[test]
fn rename_rejects_same_name() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-same"])
.assert()
.success();
limb()
.current_dir(&repo)
.args(["rename", "feat-same", "feat-same"])
.assert()
.failure()
.stderr(contains("identical"));
}
#[test]
fn rename_dry_run_preserves_original() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-dr"])
.assert()
.success();
limb()
.current_dir(&repo)
.args(["rename", "feat-dr", "feat-new", "--dry-run"])
.assert()
.success()
.stderr(contains("would rename"));
let out = limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.get_output()
.stdout
.clone();
assert!(String::from_utf8_lossy(&out).contains("feat-dr"));
}
#[test]
fn clean_reports_no_stale_when_clean() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["clean", "--yes"])
.assert()
.success()
.stderr(contains("no stale"));
}
#[test]
fn clean_dry_run_is_safe() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["clean", "--dry-run"])
.assert()
.success();
}
#[test]
fn migrate_on_worktree_errors() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.arg("add")
.arg("wt")
.assert()
.success();
let wt = repo.parent().unwrap().join("wt");
limb()
.current_dir(&wt)
.arg("migrate")
.assert()
.failure()
.stderr(contains("does not look like a plain git clone"));
}
#[test]
fn migrate_dry_run_preserves_repo() {
let (_tmp, repo) = sandbox();
let git_dir_before = repo.join(".git").exists();
limb()
.current_dir(&repo)
.args(["migrate", "--dry-run"])
.assert()
.success()
.stderr(contains("dry run"));
assert_eq!(repo.join(".git").exists(), git_dir_before);
assert!(!repo.join(".bare").exists());
}
#[test]
fn pick_help_works() {
let tmp = tempfile::tempdir().expect("tempdir");
limb()
.current_dir(tmp.path())
.args(["pick", "--help"])
.assert()
.success()
.stdout(contains("picker"));
}
#[test]
fn pick_outside_repo_fails() {
let tmp = tempfile::tempdir().expect("tempdir");
limb()
.current_dir(tmp.path())
.arg("pick")
.assert()
.failure();
}
#[cfg(unix)]
#[test]
fn mark_cd_writes_marker_with_tmux_pane() {
let target = tempfile::tempdir().expect("target");
let marker_dir = tempfile::tempdir().expect("marker dir");
let pane = "%limb_test99";
limb()
.env("TMUX_PANE", pane)
.env("TMPDIR", marker_dir.path())
.args(["mark-cd", target.path().to_str().unwrap()])
.assert()
.success();
let marker = marker_dir.path().join(format!("limb-pending-cd-{pane}"));
let contents = std::fs::read_to_string(&marker).expect("read marker");
let canon = target.path().canonicalize().expect("canonicalize");
assert_eq!(contents.trim(), canon.to_string_lossy());
}
#[test]
fn mark_cd_noop_without_tmux_pane() {
let target = tempfile::tempdir().expect("target");
let marker_dir = tempfile::tempdir().expect("marker dir");
limb()
.env_remove("TMUX_PANE")
.env("TMPDIR", marker_dir.path())
.args(["mark-cd", target.path().to_str().unwrap()])
.assert()
.success();
let mut entries = std::fs::read_dir(marker_dir.path()).expect("read marker dir");
assert!(entries.next().is_none(), "no marker should be written");
}
#[test]
fn suggests_similar_name_on_miss() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feature"])
.assert()
.success();
limb()
.current_dir(&repo)
.args(["cd", "feautre"])
.assert()
.failure()
.stderr(contains("did you mean 'feature'"));
}
#[test]
fn add_detach_creates_detached_head() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "scratch", "--detach"])
.assert()
.success();
let out = limb()
.current_dir(&repo)
.args(["--json", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let trees: serde_json::Value = serde_json::from_slice(&out).expect("json");
let scratch = trees
.as_array()
.unwrap()
.iter()
.find(|t| t["name"] == "scratch")
.expect("scratch worktree");
assert!(
scratch.get("branch").is_none_or(serde_json::Value::is_null),
"detached worktree should have no branch, got: {scratch}"
);
}
#[test]
fn add_orphan_creates_orphan_branch() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "docs-only", "--orphan"])
.assert()
.success();
limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.stdout(contains("docs-only"));
}
#[test]
fn add_lock_locks_worktree_after_creation() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "holding", "--lock", "--reason", "wip"])
.assert()
.success();
limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.stdout(contains("holding"))
.stdout(contains("(locked)"));
}
#[test]
fn reason_without_lock_is_rejected() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "x", "--reason", "foo"])
.assert()
.failure()
.stderr(contains("--lock"));
}
#[cfg(unix)]
#[test]
fn post_add_hook_runs() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, repo) = sandbox();
std::fs::create_dir_all(repo.join("hooks")).expect("mkdir hooks");
let hook_path = repo.join("hooks/post_add.sh");
let marker = repo.join(".hook-ran");
let script = format!(
"#!/usr/bin/env bash\ntouch {}\necho $LIMB_WORKTREE_NAME > {}.name\n",
marker.display(),
marker.display()
);
std::fs::write(&hook_path, script).expect("write hook");
std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))
.expect("chmod hook");
std::fs::write(
repo.join(".limb.toml"),
"[hooks]\npost_add = \"hooks/post_add.sh\"\n",
)
.expect("write .limb.toml");
limb()
.current_dir(&repo)
.args(["add", "feat-hook"])
.assert()
.success();
assert!(marker.exists(), "post_add hook should have created marker");
let name_file = repo.join(".hook-ran.name");
let contents = std::fs::read_to_string(&name_file).expect("read name marker");
assert_eq!(contents.trim(), "feat-hook");
}
#[cfg(windows)]
#[test]
fn hooks_pre_add_runs_and_can_abort_windows() {
let (_tmp, repo) = sandbox();
let scripts = repo.join("scripts");
std::fs::create_dir_all(&scripts).unwrap();
let hook = scripts.join("pre_add.cmd");
std::fs::write(&hook, "@echo off\r\nexit /b 1\r\n").unwrap();
std::fs::write(
repo.join(".limb.toml"),
"[hooks]\npre_add = \"scripts/pre_add.cmd\"\n",
)
.unwrap();
limb()
.current_dir(&repo)
.args(["add", "feat-blocked"])
.assert()
.failure()
.stderr(contains("pre_add"));
}
#[cfg(windows)]
#[test]
fn hooks_post_add_failure_does_not_abort_worktree_windows() {
let (_tmp, repo) = sandbox();
let scripts = repo.join("scripts");
std::fs::create_dir_all(&scripts).unwrap();
let hook = scripts.join("post_add.cmd");
std::fs::write(&hook, "@echo off\r\nexit /b 7\r\n").unwrap();
std::fs::write(
repo.join(".limb.toml"),
"[hooks]\npost_add = \"scripts/post_add.cmd\"\n",
)
.unwrap();
limb()
.current_dir(&repo)
.args(["add", "feat-best"])
.assert()
.success();
limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.stdout(contains("feat-best"));
}
#[cfg(windows)]
#[test]
fn post_add_hook_runs_windows() {
let (_tmp, repo) = sandbox();
std::fs::create_dir_all(repo.join("hooks")).expect("mkdir hooks");
let hook_path = repo.join("hooks/post_add.cmd");
let marker = repo.join(".hook-ran");
let name_marker = repo.join(".hook-ran.name");
let script = format!(
"@echo off\r\ntype nul > \"{marker}\"\r\n>\"{name_marker}\" echo %LIMB_WORKTREE_NAME%\r\n",
marker = marker.display(),
name_marker = name_marker.display(),
);
std::fs::write(&hook_path, script).expect("write hook");
std::fs::write(
repo.join(".limb.toml"),
"[hooks]\npost_add = \"hooks/post_add.cmd\"\n",
)
.expect("write .limb.toml");
limb()
.current_dir(&repo)
.args(["add", "feat-hook"])
.assert()
.success();
assert!(marker.exists(), "post_add hook should have created marker");
let contents = std::fs::read_to_string(&name_marker).expect("read name marker");
assert_eq!(contents.trim(), "feat-hook");
}
#[test]
fn add_dry_run_does_not_create() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-pretend", "--dry-run"])
.assert()
.success()
.stderr(contains("would add"));
let parent = repo.parent().unwrap();
assert!(!parent.join("feat-pretend").exists());
}
#[test]
fn remove_dry_run_preserves_worktree() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-keep"])
.assert()
.success();
let parent = repo.parent().unwrap();
assert!(parent.join("feat-keep").exists());
limb()
.current_dir(&repo)
.args(["remove", "feat-keep", "--dry-run"])
.assert()
.success()
.stderr(contains("would remove"));
assert!(
parent.join("feat-keep").exists(),
"worktree should still exist after dry-run"
);
}
#[test]
fn lock_dry_run_does_not_lock() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-lockdr"])
.assert()
.success();
limb()
.current_dir(&repo)
.args(["lock", "feat-lockdr", "--dry-run"])
.assert()
.success()
.stderr(contains("would lock"));
let out = limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8_lossy(&out);
let line = stdout
.lines()
.find(|l| l.contains("feat-lockdr"))
.expect("feat-lockdr should appear in list");
assert!(
!line.contains("(locked)"),
"worktree should not be locked after dry-run"
);
}
#[test]
fn unlock_dry_run_does_not_unlock() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-unlockdr"])
.assert()
.success();
limb()
.current_dir(&repo)
.args(["lock", "feat-unlockdr"])
.assert()
.success();
limb()
.current_dir(&repo)
.args(["unlock", "feat-unlockdr", "--dry-run"])
.assert()
.success()
.stderr(contains("would unlock"));
let out = limb()
.current_dir(&repo)
.arg("list")
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8_lossy(&out);
let line = stdout
.lines()
.find(|l| l.contains("feat-unlockdr"))
.expect("feat-unlockdr should appear in list");
assert!(
line.contains("(locked)"),
"worktree should still be locked after unlock dry-run"
);
}
#[test]
fn update_dry_run_announces_plan() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["update", "--dry-run"])
.assert()
.success()
.stderr(contains("would fetch"));
}
#[test]
fn add_track_requires_from() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-track", "--track"])
.assert()
.failure()
.stderr(contains("--from"));
}
#[test]
fn add_no_track_requires_from() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-no-track", "--no-track"])
.assert()
.failure()
.stderr(contains("--from"));
}
#[test]
fn add_track_and_no_track_conflict() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args([
"add",
"feat-conflict",
"--from",
"main",
"--track",
"--no-track",
])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn add_with_track_creates_worktree() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-tracked", "--from", "main", "--track"])
.assert()
.success();
let parent = repo.parent().unwrap();
assert!(parent.join("feat-tracked").exists());
}
#[test]
fn repair_accepts_path_argument() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-rep"])
.assert()
.success();
let parent = repo.parent().unwrap();
let wt = parent.join("feat-rep");
limb()
.current_dir(&repo)
.args(["repair", wt.to_str().unwrap()])
.assert()
.success()
.stderr(contains("repaired"));
}
#[test]
fn list_verbose_shows_lock_reason() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-locked"])
.assert()
.success();
limb()
.current_dir(&repo)
.args(["lock", "feat-locked", "--reason", "in use by build"])
.assert()
.success();
let out = limb()
.current_dir(&repo)
.args(["list", "-v"])
.assert()
.success()
.get_output()
.stdout
.clone();
let stdout = String::from_utf8_lossy(&out);
assert!(
stdout.contains("locked: in use by build"),
"list -v should show lock reason, got: {stdout}"
);
}
#[test]
fn add_no_checkout_smoke() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-nc", "--no-checkout"])
.assert()
.success();
let parent = repo.parent().unwrap();
assert!(parent.join("feat-nc").exists());
}
#[test]
fn add_force_branch_requires_from() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-fb", "-B", "newbranch"])
.assert()
.failure()
.stderr(contains("--from"));
}
#[test]
fn add_force_branch_overwrites_existing() {
let (_tmp, repo) = sandbox();
git(&repo, &["branch", "existing-feat"]);
limb()
.current_dir(&repo)
.args(["add", "wt-fb", "-B", "existing-feat", "--from", "main"])
.assert()
.success();
let parent = repo.parent().unwrap();
assert!(parent.join("wt-fb").exists());
}
#[test]
fn add_guess_remote_requires_from() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-gr", "--guess-remote"])
.assert()
.failure()
.stderr(contains("--from"));
}
#[test]
fn add_guess_remote_pair_conflict() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args([
"add",
"feat-gr",
"--from",
"main",
"--guess-remote",
"--no-guess-remote",
])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn add_relative_paths_pair_conflict() {
let (_tmp, repo) = sandbox();
limb()
.current_dir(&repo)
.args(["add", "feat-rp", "--relative-paths", "--no-relative-paths"])
.assert()
.failure()
.stderr(contains("cannot be used"));
}
#[test]
fn quiet_suppresses_git_progress() {
let (_tmp, repo) = sandbox();
let output = limb()
.current_dir(&repo)
.args(["--quiet", "add", "feat-q"])
.assert()
.success()
.get_output()
.clone();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("Preparing"),
"git progress should be suppressed under --quiet, got: {stderr:?}"
);
assert!(
!stderr.contains("✓"),
"limb's success marker should be suppressed under --quiet, got: {stderr:?}"
);
}
#[cfg(unix)]
#[test]
fn add_dry_run_skips_hooks() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, repo) = sandbox();
std::fs::create_dir_all(repo.join("hooks")).expect("mkdir hooks");
let hook_path = repo.join("hooks/post_add.sh");
let marker = repo.join(".hook-ran");
let script = format!("#!/usr/bin/env bash\ntouch {}\n", marker.display());
std::fs::write(&hook_path, script).expect("write hook");
std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))
.expect("chmod hook");
std::fs::write(
repo.join(".limb.toml"),
"[hooks]\npost_add = \"hooks/post_add.sh\"\n",
)
.expect("write .limb.toml");
limb()
.current_dir(&repo)
.args(["add", "feat-no-hook", "--dry-run"])
.assert()
.success();
assert!(!marker.exists(), "hook should not run in dry-run");
let parent = repo.parent().unwrap();
assert!(!parent.join("feat-no-hook").exists());
}