use super::*;
use crate::command_test_support::os_arguments;
use crate::test_support::{fixture, git, remove, repository};
use std::process::Output;
fn plan_of(values: &[&str], subcommand_index: usize, start: &Path) -> Option<AddPlan> {
return parse_add_plan(os_arguments(values).as_slice(), subcommand_index, start);
}
#[test]
fn an_accepted_form_names_its_destination_and_the_insertion_point() {
let root: PathBuf = fixture("add-plan-accepted");
let canonical: PathBuf = std::fs::canonicalize(root.as_path()).expect("canonical");
for (values, index, operand) in [
(vec!["worktree", "add", "topic"], 0, "topic"),
(vec!["worktree", "add", "-b", "feature", "topic"], 0, "topic"),
(vec!["worktree", "add", "topic", "-b", "feature", "main"], 0, "topic"),
(vec!["worktree", "add", "--detach", "--quiet", "topic", "main"], 0, "topic"),
(vec!["worktree", "add", "-d", "-q", "--lock", "--reason", "why", "topic"], 0, "topic"),
(vec!["worktree", "add", "--reason=why", "--checkout", "topic"], 0, "topic"),
(vec!["worktree", "add", "--", "-odd"], 0, "-odd"),
(vec!["--no-pager", "worktree", "add", "topic"], 1, "topic"),
] {
let plan: AddPlan = plan_of(values.as_slice(), index, root.as_path()).unwrap_or_else(|| { panic!("{values:?} is accepted"); });
assert_eq!(plan.destination, canonical.join(operand), "{values:?}");
assert_eq!(plan.insert_at, index + 2, "{values:?}");
assert_eq!(os_arguments(values.as_slice())[plan.insert_at - 1], "add", "{values:?}");
let quiet: bool = values.contains(&"--quiet") || values.contains(&"-q");
assert_eq!(plan.quiet, quiet, "{values:?}");
}
remove(root.as_path());
}
#[test]
fn a_form_outside_the_table_is_declined() {
let root: PathBuf = fixture("add-plan-declined");
for values in [
vec!["worktree", "add"],
vec!["worktree", "add", "a", "b", "c"],
vec!["worktree", "add", "--no-checkout", "topic"],
vec!["worktree", "add", "--force", "topic"],
vec!["worktree", "add", "-f", "topic"],
vec!["worktree", "add", "-B", "feature", "topic"],
vec!["worktree", "add", "--orphan", "topic"],
vec!["worktree", "add", "--track", "topic"],
vec!["worktree", "add", "--detac", "topic"],
vec!["worktree", "add", "-dq", "topic"],
vec!["worktree", "add", "-bfeature", "topic"],
vec!["worktree", "add", "-b"],
vec!["worktree", "add", "topic", "-b"],
vec!["worktree", "add", "-b", "feature"],
vec!["worktree", "move", "topic", "other"],
vec!["worktree", "ad", "topic"],
vec!["worktree", "--force", "add", "topic"],
vec!["worktree", "add", "."],
vec!["worktree", "add", ".."],
vec!["worktree", "add", "missing-parent/topic"],
vec!["add", "topic"],
] {
assert_eq!(plan_of(values.as_slice(), 0, root.as_path()), None, "{values:?}");
}
remove(root.as_path());
}
#[test]
fn the_global_prefix_decides_the_start_directory() {
let root: PathBuf = fixture("add-plan-prefix");
std::fs::create_dir(root.join("inner")).expect("inner");
let canonical: PathBuf = std::fs::canonicalize(root.as_path()).expect("canonical");
let moved: PathBuf = start_directory(os_arguments(&["-C", "inner", "--no-pager"]).as_slice(), root.as_path()).expect("moved");
let plan: AddPlan = parse_add_plan(os_arguments(&["worktree", "add", "topic"]).as_slice(), 0, moved.as_path()).expect("plan");
assert_eq!(plan.destination, canonical.join("inner").join("topic"));
let chained: PathBuf = start_directory(os_arguments(&["-C", "inner", "-C", "."]).as_slice(), root.as_path()).expect("chained");
assert_eq!(chained, root.join("inner").join("."));
for prefix in [
vec!["-c", "core.autocrlf=true"],
vec!["--git-dir", "x"],
vec!["--work-tree", "x"],
vec!["--config-env", "a=B"],
vec!["-C", "inner/../inner"],
vec!["-C"],
vec!["--bare"],
] {
assert_eq!(start_directory(os_arguments(prefix.as_slice()).as_slice(), root.as_path()), None, "{prefix:?}");
}
assert_eq!(start_directory(&[], root.as_path()), Some(root.clone()));
remove(root.as_path());
}
#[test]
fn a_symbolic_link_parent_is_resolved_like_git_does() {
let root: PathBuf = fixture("add-plan-link");
std::fs::create_dir(root.join("real")).expect("real");
std::os::unix::fs::symlink(root.join("real"), root.join("link")).expect("link");
let plan = plan_of(&["worktree", "add", "link/topic"], 0, root.as_path()).expect("plan");
assert_eq!(plan.destination, std::fs::canonicalize(root.join("real")).expect("canonical").join("topic"));
remove(root.as_path());
}
#[test]
fn real_git_registers_the_planned_destination_for_each_accepted_form() {
let root: PathBuf = fixture("add-plan-real-git");
let repo: PathBuf = repository(root.as_path(), "repo");
std::fs::create_dir(root.join("space dir")).expect("directory");
std::os::unix::fs::symlink(root.join("space dir"), root.join("via-link")).expect("link");
for (position, values) in [
vec!["worktree", "add", "../one"],
vec!["worktree", "add", "-b", "feature", "../two", "main"],
vec!["worktree", "add", "--detach", "--quiet", "../three", "main"],
vec!["worktree", "add", "../four", "-b", "other"],
vec!["worktree", "add", "--lock", "--reason", "plan", "../five", "-b", "locked-one"],
vec!["worktree", "add", "--", "../six"],
vec!["worktree", "add", "../via-link/seven", "-b", "seventh"],
]
.into_iter()
.enumerate()
{
let plan: AddPlan = plan_of(values.as_slice(), 0, repo.as_path()).unwrap_or_else(|| { panic!("{values:?} is accepted"); });
let before: String = String::from_utf8_lossy(&git(repo.as_path(), &["worktree", "list", "--porcelain"]).stdout).into_owned();
assert!(!before.contains(plan.destination.to_string_lossy().as_ref()), "{position}: {values:?}");
git(repo.as_path(), values.as_slice());
let listing: Output = git(repo.as_path(), &["worktree", "list", "--porcelain"]);
let text: String = String::from_utf8_lossy(&listing.stdout).into_owned();
assert!(text.contains(format!("worktree {}\n", plan.destination.display()).as_str()), "{values:?}: {text}");
}
remove(root.as_path());
}
#[test]
fn a_destination_that_is_a_link_is_declined() {
let root: PathBuf = fixture("add-plan-final-link");
std::fs::create_dir(root.join("real")).expect("real");
std::os::unix::fs::symlink(root.join("real"), root.join("link")).expect("link");
assert_eq!(plan_of(&["worktree", "add", "link"], 0, root.as_path()), None);
assert_eq!(plan_of(&["worktree", "add", "link/"], 0, root.as_path()), None, "a trailing slash names the same link");
assert!(plan_of(&["worktree", "add", "fresh"], 0, root.as_path()).is_some(), "positive control");
assert!(plan_of(&["worktree", "add", "real"], 0, root.as_path()).is_some(), "a real directory is Git's to accept");
remove(root.as_path());
}
#[test]
fn the_copy_destination_is_read_from_every_documented_form() {
let root: PathBuf = fixture("copy-destination-forms");
let canonical: PathBuf = std::fs::canonicalize(root.as_path()).expect("canonical");
for (values, index) in [
(vec!["worktree", "add", "topic"], 0),
(vec!["worktree", "add", "-f", "topic"], 0),
(vec!["worktree", "add", "--force", "--no-checkout", "topic", "main"], 0),
(vec!["worktree", "add", "-B", "feature", "topic"], 0),
(vec!["worktree", "add", "-bfeature", "topic"], 0),
(vec!["worktree", "add", "-fb", "feature", "topic"], 0),
(vec!["worktree", "add", "-dq", "topic"], 0),
(vec!["worktree", "add", "--orphan", "-b", "feature", "topic"], 0),
(vec!["worktree", "add", "--track", "--guess-remote", "--relative-paths", "topic"], 0),
(vec!["worktree", "add", "--no-track", "--no-guess-remote", "topic"], 0),
(vec!["worktree", "add", "--det", "topic"], 0),
(vec!["worktree", "add", "--lock", "--reas", "why", "topic"], 0),
(vec!["worktree", "add", "--lock", "--reas=why", "topic"], 0),
(vec!["worktree", "add", "--no-che", "--qu", "topic"], 0),
(vec!["worktree", "add", "--end-of-options", "-odd"], 0),
(vec!["--git-dir=/g", "--work-tree=/w", "worktree", "add", "topic"], 2),
(vec!["--git-dir", "/g", "--work-tree", "/w", "--namespace", "n", "worktree", "add", "topic"], 6),
(vec!["--bare", "--attr-source=HEAD", "worktree", "add", "topic"], 2),
(vec!["worktree", "add", "--lock", "--reason=why", "topic"], 0),
(vec!["worktree", "add", "--no-lock", "--no-reason", "--quiet", "--no-quiet", "topic"], 0),
(vec!["worktree", "add", "topic", "--detach", "main"], 0),
(vec!["worktree", "add", "--", "-odd"], 0),
(vec!["-c", "core.x=y", "worktree", "add", "topic"], 2),
(vec!["--no-optional-locks", "--config-env", "a.b=C", "worktree", "add", "topic"], 3),
] {
let found: PathBuf = parse_copy_destination(os_arguments(values.as_slice()).as_slice(), index, canonical.as_path())
.unwrap_or_else(|| { panic!("{values:?} names a destination"); });
let operand: &str = if values.contains(&"-odd") { "-odd" } else { "topic" };
assert_eq!(found, canonical.join(operand), "{values:?}");
}
remove(root.as_path());
}
#[test]
fn a_copy_destination_that_is_not_read_stays_unknown() {
let root: PathBuf = fixture("copy-destination-unknown");
for (values, index) in [
(vec!["worktree", "add", "--detac", "--", "-x", "y", "z"], 0),
(vec!["worktree", "add", "--re", "topic"], 0),
(vec!["worktree", "add", "--n", "topic"], 0),
(vec!["worktree", "add", "--no-", "topic"], 0),
(vec!["worktree", "add", "--no-no-checkout", "topic"], 0),
(vec!["worktree", "add", "--detach=yes", "topic"], 0),
(vec!["worktree", "add", "--track=direct", "topic"], 0),
(vec!["worktree", "add", "--no-reason=x", "topic"], 0),
(vec!["worktree", "add", "--reason"], 0),
(vec!["worktree", "add", "-d=x", "topic"], 0),
(vec!["worktree", "add", "-h"], 0),
(vec!["worktree", "add", "--help", "topic"], 0),
(vec!["worktree", "add", "-b"], 0),
(vec!["worktree", "add", "topic", "-b"], 0),
(vec!["worktree", "add", "-bx"], 0),
(vec!["worktree", "add", "-x", "topic"], 0),
(vec!["worktree", "add"], 0),
(vec!["worktree", "add", "a", "b", "c"], 0),
(vec!["worktree", "move", "a", "b"], 0),
(vec!["worktree", "--verbose", "add", "topic"], 0),
(vec!["status", "add", "topic"], 0),
] {
assert_eq!(parse_copy_destination(os_arguments(values.as_slice()).as_slice(), index, root.as_path()), None, "{values:?}");
}
remove(root.as_path());
}
#[test]
fn only_dash_c_capital_moves_the_start_of_a_copy_destination() {
let start: &Path = Path::new("/work");
assert_eq!(copy_start_directory(os_arguments(&["-C", "sub", "-C", "deeper"]).as_slice(), start), Some(PathBuf::from("/work/sub/deeper")));
assert_eq!(copy_start_directory(os_arguments(&["-c", "a.b=c", "--no-pager", "-P"]).as_slice(), start), Some(PathBuf::from("/work")));
assert_eq!(copy_start_directory(os_arguments(&["--config-env=a.b=C"]).as_slice(), start), Some(PathBuf::from("/work")));
assert_eq!(copy_start_directory(os_arguments(&["--git-dir=/g", "--work-tree", "/w", "--bare", "--exec-path=/x"]).as_slice(), start), Some(PathBuf::from("/work")));
assert_eq!(copy_start_directory(os_arguments(&["-C", ""]).as_slice(), start), Some(PathBuf::from("/work")));
for refused in [vec!["--unknown"], vec!["--git-dir"], vec!["-C"], vec!["-c"], vec!["--exec-path"]] {
assert_eq!(copy_start_directory(os_arguments(refused.as_slice()).as_slice(), start), None, "{refused:?}");
}
}
#[test]
fn real_git_registers_the_destination_the_copy_reading_names() {
let root: PathBuf = fixture("copy-destination-real");
let repo: PathBuf = repository(root.as_path(), "repo");
let canonical: PathBuf = std::fs::canonicalize(root.as_path()).expect("canonical");
for (index, values) in [
vec!["worktree", "add", "-fb", "one", "../one"],
vec!["worktree", "add", "-bfeature", "../two"],
vec!["worktree", "add", "--orphan", "-b", "three", "../three"],
vec!["worktree", "add", "--detach", "--no-checkout", "../four", "main"],
].into_iter().enumerate() {
let start: PathBuf = std::fs::canonicalize(repo.as_path()).expect("canonical repo");
let named: PathBuf = parse_copy_destination(os_arguments(values.as_slice()).as_slice(), 0, start.as_path()).unwrap_or_else(|| { panic!("{values:?}"); });
git(repo.as_path(), values.as_slice());
assert!(named.join(".git").exists(), "{index}: Git made {}", named.display());
assert_eq!(named.parent(), Some(canonical.as_path()));
}
remove(root.as_path());
}
#[test]
fn a_long_option_resolves_the_way_gits_parser_does() {
let name = |text: &str| -> Option<(&'static str, bool, Option<String>)> {
return resolve_long(text).map(|found| { return (found.option.long.unwrap_or("-"), found.unset, found.attached.map(String::from)); });
};
assert_eq!(name("detach"), Some(("detach", false, None)));
assert_eq!(name("det"), Some(("detach", false, None)), "a unique prefix");
assert_eq!(name("d"), Some(("detach", false, None)));
assert_eq!(name("no-che"), Some(("checkout", true, None)), "a negated prefix");
assert_eq!(name("no-checkout"), Some(("checkout", true, None)));
assert_eq!(name("reas=why"), Some(("reason", false, Some(String::from("why")))), "a prefix with its value");
assert_eq!(name("reason="), Some(("reason", false, Some(String::new()))), "an empty value is a value");
assert_eq!(name("re"), None, "reason and relative-paths");
assert_eq!(name("rel"), Some(("relative-paths", false, None)));
assert_eq!(name("f"), Some(("force", false, None)));
assert_eq!(name("g"), Some(("guess-remote", false, None)));
assert_eq!(name("l"), Some(("lock", false, None)));
assert_eq!(name("o"), Some(("orphan", false, None)));
assert_eq!(name("t"), Some(("track", false, None)));
assert_eq!(name("n"), None, "a prefix of no- abbreviates every negation");
assert_eq!(name("no"), None);
assert_eq!(name("no-"), None);
assert_eq!(name("no-no-checkout"), None, "a double negation");
assert_eq!(name("nonsense"), None);
assert_eq!(name("detachx"), None, "a longer text than the name is not a prefix");
assert_eq!(name("c"), Some(("checkout", false, None)));
}
#[test]
fn values_are_taken_as_gits_parser_takes_them() {
let region = |values: &[&str]| -> Option<usize> { return copy_add_operands(os_arguments(values).as_slice()).map(|operands| { return operands.len(); }); };
assert_eq!(region(&["--reason", "why", "topic"]), Some(1), "a value in the next token");
assert_eq!(region(&["--no-reason", "topic"]), Some(1), "a negated option takes none");
assert_eq!(region(&["--no-reason=x", "topic"]), None);
assert_eq!(region(&["--quiet=1", "topic"]), None, "a flag takes no value");
assert_eq!(region(&["-qf", "topic"]), Some(1));
assert_eq!(region(&["-fbname", "topic"]), Some(1), "the rest of a token is the value");
assert_eq!(region(&["-fb", "name", "topic"]), Some(1), "the next token is the value");
assert_eq!(region(&["-fb"]), None);
assert_eq!(region(&["topic", "--detach", "main"]), Some(2), "options may follow operands");
assert_eq!(region(&["--", "--detach", "x"]), Some(2), "after -- everything is an operand");
}
#[test]
fn a_destination_below_missing_directories_is_read_without_them() {
let root: PathBuf = fixture("copy-destination-missing");
let canonical: PathBuf = std::fs::canonicalize(root.as_path()).expect("canonical");
let link: PathBuf = root.join("link");
std::os::unix::fs::symlink(canonical.join("real"), link.as_path()).expect("link");
std::fs::create_dir(canonical.join("real")).expect("real");
let read = |operand: &str| -> Option<PathBuf> { return creating_destination(root.as_path(), OsStr::new(operand)); };
assert_eq!(read(".claude/worktrees/topic"), Some(canonical.join(".claude/worktrees/topic")));
assert_eq!(read("real/inner/topic"), Some(canonical.join("real/inner/topic")));
assert_eq!(read("link/inner/topic"), Some(canonical.join("real/inner/topic")), "a link in the existing part resolves");
assert_eq!(read("link"), None, "a destination that is a link is declined");
assert_eq!(read("missing/../topic"), None);
assert_eq!(read("missing/./topic"), Some(canonical.join("missing/topic")));
assert_eq!(read("."), None);
assert_eq!(read("a/.."), None);
remove(root.as_path());
}
#[test]
fn the_option_table_matches_what_git_prints_for_dash_h() {
let root: PathBuf = fixture("add-plan-dash-h");
let repo: PathBuf = repository(root.as_path(), "repo");
let output: Output = std::process::Command::new(crate::test_support::REAL_GIT)
.args(["worktree", "add", "-h"])
.current_dir(repo.as_path())
.env("GIT_CONFIG_NOSYSTEM", "1")
.output()
.expect("git runs");
remove(root.as_path());
let text: String = String::from_utf8_lossy(output.stdout.as_slice()).into_owned() + String::from_utf8_lossy(output.stderr.as_slice()).as_ref();
let listed: Vec<(Option<u8>, Option<String>, bool)> = text
.lines()
.skip_while(|line| { return !line.is_empty(); })
.filter(|line| { return line.starts_with(" -"); })
.map(|line| {
let mut short: Option<u8> = None;
let mut long: Option<String> = None;
let mut takes: bool = false;
for word in line.split_whitespace() {
let head: &str = word.trim_end_matches(',');
if let Some(rest) = head.strip_prefix("--") {
long = Some(rest.replace("[no-]", ""));
} else if let Some(letter) = head.strip_prefix('-') {
short = letter.bytes().next();
} else if head.starts_with('<') {
takes = true;
} else {
break;
}
}
return (short, long, takes);
})
.collect();
let table: Vec<(Option<u8>, Option<String>, bool)> = ADD_OPTIONS
.iter()
.map(|option| { return (option.short, option.long.map(String::from), option.takes == Takes::Required); })
.collect();
assert!(!listed.is_empty(), "the usage lists options: {text}");
for entry in &listed {
assert!(table.contains(entry), "Git lists {entry:?}, which the table lacks: {text}");
}
for entry in &table {
assert!(listed.contains(entry), "the table lists {entry:?}, which Git does not: {text}");
}
}
#[test]
fn an_exact_name_wins_over_the_names_it_is_a_prefix_of() {
static TABLE: &[AddOption] = &[
AddOption { short: None, long: Some("reason"), takes: Takes::Nothing },
AddOption { short: None, long: Some("re"), takes: Takes::Required },
AddOption { short: None, long: Some("reset"), takes: Takes::Nothing },
];
let exact = resolve_in(TABLE, "re").expect("the exact name");
assert_eq!(exact.option.long, Some("re"));
assert!(resolve_in(TABLE, "res").is_some_and(|prefix| { return prefix.option.long == Some("reset"); }), "a unique prefix");
assert!(resolve_in(TABLE, "rea").is_some_and(|prefix| { return prefix.option.long == Some("reason"); }));
assert!(resolve_in(TABLE, "r").is_none(), "three names share the prefix");
assert!(resolve_in(TABLE, "re=1").is_some_and(|valued| { return valued.attached == Some("1"); }));
}