use gwm::config::{BranchType, Config, WorktreeConfig};
use gwm::naming::{
branch_pattern_warning, default_branch_types, kebab, worktree_spec, BranchParser, BranchSpec, WorktreeName,
BRANCH_TYPES,
};
#[test]
fn naming_regexes_compile_at_first_use() {
let _ = BranchSpec::new("feat", "1", "x"); let _ = BranchParser::builtin().parse("feat/#1-x"); }
#[test]
fn kebab_normalizes() {
assert_eq!(kebab("Hello World"), "hello-world");
assert_eq!(kebab("Foo_BAR baz"), "foo-bar-baz");
assert_eq!(kebab("--leading--"), "leading");
assert_eq!(kebab(" spaces "), "spaces");
assert_eq!(kebab("ALL CAPS"), "all-caps");
assert_eq!(kebab(""), "");
assert_eq!(kebab("__"), "");
}
#[test]
fn kebab_treats_punctuation_as_separator() {
assert_eq!(kebab("foo!@#bar"), "foo-bar");
assert_eq!(kebab("hello.world"), "hello-world");
assert_eq!(kebab("v1.2.3"), "v1-2-3");
}
#[test]
fn branch_validation() {
assert!(BranchSpec::new("feat", "123", "user-auth").is_ok());
assert!(BranchSpec::new("nope", "123", "x").is_err());
assert!(BranchSpec::new("feat", "abc", "x").is_err());
assert!(BranchSpec::new("feat", "123", "").is_err());
}
#[test]
fn all_branch_types_accepted() {
for (t, _) in BRANCH_TYPES {
assert!(BranchSpec::new(*t, "1", "x").is_ok(), "type {} should be valid", t);
}
}
#[test]
fn invalid_issue_must_be_digits() {
assert!(BranchSpec::new("feat", "abc", "x").is_err());
assert!(BranchSpec::new("feat", "12a", "x").is_err());
assert!(BranchSpec::new("feat", "", "x").is_err());
}
#[test]
fn description_normalized_before_validation() {
let spec = BranchSpec::new("feat", "1", "My New Feature").unwrap();
assert_eq!(spec.desc, "my-new-feature");
}
#[test]
fn parse_roundtrip() {
let parsed = BranchParser::builtin().parse("feat/#42-cool-feature").unwrap();
assert_eq!(parsed.type_, "feat");
assert_eq!(parsed.issue, "42");
assert_eq!(parsed.desc, "cool-feature");
}
#[test]
fn parse_rejects_garbage() {
assert!(BranchParser::builtin().parse("garbage").is_none());
assert!(BranchParser::builtin().parse("feat/no-issue").is_none());
assert!(BranchParser::builtin().parse("FEAT/#1-x").is_none()); }
#[test]
fn renders_paths() {
let cfg = WorktreeConfig::default();
let spec = BranchSpec::new("feat", "10", "x").unwrap();
assert_eq!(spec.branch_name(&cfg, "myrepo").unwrap(), "feat/#10-x");
assert_eq!(spec.worktree_dirname(&cfg, "myrepo").unwrap(), "feat-10-x");
let p = spec
.worktree_path(&cfg, "myrepo", std::path::Path::new("/repos/myrepo"))
.unwrap();
assert!(p.ends_with(std::path::Path::new("cc-worktree").join("myrepo").join("feat-10-x")));
}
#[test]
fn default_branch_types_matches_const_table() {
let runtime = default_branch_types();
assert_eq!(runtime.len(), BRANCH_TYPES.len());
for ((cname, cdesc), bt) in BRANCH_TYPES.iter().zip(runtime.iter()) {
assert_eq!(*cname, bt.name);
assert_eq!(*cdesc, bt.description);
}
}
#[test]
fn new_with_custom_types_rejects_default_built_in() {
let custom = vec![BranchType {
name: "migration".into(),
description: "Database migration".into(),
}];
let err = BranchSpec::new_with_types("feat", "1", "x", &custom).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("invalid branch type 'feat'"), "got: {msg}");
assert!(
msg.contains("migration"),
"error must list the allowed types — got: {msg}"
);
assert!(
!msg.contains("feat, fix"),
"error must not leak the built-in default list — got: {msg}"
);
}
#[test]
fn new_with_custom_types_accepts_listed_name() {
let custom = vec![
BranchType {
name: "feat".into(),
description: "Feature".into(),
},
BranchType {
name: "migration".into(),
description: "Database migration".into(),
},
];
let spec = BranchSpec::new_with_types("migration", "42", "users-table", &custom).expect("ok");
assert_eq!(spec.type_, "migration");
}
#[test]
fn invalid_type_error_lists_allowed_names_from_defaults() {
let err = BranchSpec::new("nope", "1", "x").unwrap_err();
let msg = format!("{}", err);
for (name, _) in BRANCH_TYPES {
assert!(msg.contains(name), "expected {name} in error message, got: {msg}");
}
}
#[test]
fn renders_with_custom_patterns() {
let cfg = WorktreeConfig {
base: "/tmp/{repo}".into(),
path_pattern: "{type}_{issue}_{desc}".into(),
branch_pattern: "release/{type}-{issue}".into(),
};
let spec = BranchSpec::new("fix", "7", "foo-bar").unwrap();
assert_eq!(spec.branch_name(&cfg, "r").unwrap(), "release/fix-7");
assert_eq!(spec.worktree_dirname(&cfg, "r").unwrap(), "fix_7_foo-bar");
let p = spec.worktree_path(&cfg, "r", std::path::Path::new("/repos/r")).unwrap();
assert_eq!(p, std::path::Path::new("/tmp/r").join("fix_7_foo-bar"));
}
#[test]
fn worktree_path_resolves_repo_parent_base() {
let cfg = WorktreeConfig {
base: "{repo_parent}/worktrees".into(),
path_pattern: "{type}-{issue}-{desc}".into(),
branch_pattern: "{type}/#{issue}-{desc}".into(),
};
let spec = BranchSpec::new("feat", "175", "repo-path").unwrap();
let repo_path = std::path::Path::new("/Users/me/Projects/Perso/gwm-cli");
let p = spec.worktree_path(&cfg, "gwm-cli", repo_path).unwrap();
assert_eq!(
p,
std::path::Path::new("/Users/me/Projects/Perso/worktrees/feat-175-repo-path")
);
}
#[test]
fn the_default_pattern_round_trips_so_no_warning() {
assert_eq!(
branch_pattern_warning("{type}/#{issue}-{desc}", "gwm-cli", &default_branch_types()),
None
);
}
const UNREADABLE: &str = "~/{type}/#{issue}-{desc}";
#[test]
fn a_pattern_the_compiler_cannot_mirror_warns_that_everything_is_inactive() {
let w = branch_pattern_warning(UNREADABLE, "gwm-cli", &default_branch_types())
.expect("a pattern nothing reads back must warn");
assert!(w.contains("branch_pattern"), "the warning must name the key: {}", w);
for expected in ["auto-linking", "gitmoji", "branch-convention"] {
assert!(
w.contains(expected),
"warning should name the '{}' consequence: {}",
expected,
w
);
}
}
#[test]
fn the_patterns_415_called_broken_now_round_trip() {
for pattern in [
"{type}-{issue}-{desc}",
"{type}/{issue}-{desc}",
"{type}/#{issue}_{desc}",
"{type}_{issue}_{desc}",
"{repo}/{type}/#{issue}-{desc}",
"wt/{type}/#{issue}-{desc}",
"{type}/#{issue}-prefix-{desc}",
"{type}/#{issue}-{desc}-{repo}",
"{desc}/#{issue}-{type}",
"{type}/#{desc}-{issue}",
] {
assert_eq!(
branch_pattern_warning(pattern, "gwm-cli", &default_branch_types()),
None,
"`{}` round-trips since #417 and must not warn",
pattern
);
}
}
#[test]
fn a_pattern_that_drops_the_issue_warns_about_auto_linking() {
let w = branch_pattern_warning("{type}/#1-{desc}", "gwm-cli", &default_branch_types())
.expect("a pattern with a frozen issue must warn");
assert!(
w.contains("auto-linking"),
"a pattern that hardcodes the issue breaks auto-linking: {}",
w
);
}
#[test]
fn a_hardcoded_type_is_fine_when_it_is_the_only_configured_type() {
let only_feat = vec![BranchType {
name: "feat".into(),
description: "New feature implementation".into(),
}];
assert_eq!(
branch_pattern_warning("feat/#{issue}-{desc}", "gwm-cli", &only_feat),
None
);
let spec = BranchParser::compile("feat/#{issue}-{desc}", "gwm-cli", &only_feat)
.expect("compiles")
.parse("feat/#42-x")
.expect("parses");
assert_eq!(spec.type_, "feat", "the frozen type is what every branch here is");
assert!(branch_pattern_warning("feat/#{issue}-{desc}", "gwm-cli", &default_branch_types()).is_some());
}
#[test]
fn a_pattern_that_hardcodes_the_desc_is_not_a_false_negative() {
let w = branch_pattern_warning("{type}/#{issue}-fixed", "gwm-cli", &default_branch_types())
.expect("a hardcoded desc must warn");
assert!(
w.contains("desc"),
"the warning must name `desc` as the broken segment: {}",
w
);
}
#[test]
fn the_warning_names_every_consumer_of_a_broken_segment() {
let w =
branch_pattern_warning("{type}/#1-{desc}", "gwm-cli", &default_branch_types()).expect("a frozen issue must warn");
assert!(w.contains("auto-linking"), "issue feeds auto-linking: {}", w);
assert!(
w.contains("hook placeholders") && w.contains("rename"),
"issue also feeds lifecycle hook placeholders and the TUI rename: {}",
w
);
}
#[test]
fn the_probe_expands_repo_with_the_real_repo_name() {
assert_eq!(
branch_pattern_warning("{repo}/{type}/#{issue}-{desc}", "gwm-cli", &default_branch_types()),
None,
"both sides must resolve `{{repo}}` to the same name"
);
let w = branch_pattern_warning("{repo}-{issue}-{desc}", "gwm-cli", &default_branch_types())
.expect("this pattern carries no `{type}`");
assert!(
w.contains("carries no `{type}`") && !w.contains("match nothing at all"),
"a dashed repo name is a literal now, not a parse failure: {}",
w
);
}
#[test]
fn every_probe_derived_verdict_is_scoped_to_the_shapes_actually_probed() {
let w = branch_pattern_warning(UNREADABLE, "gwm-cli", &default_branch_types()).expect("must warn");
assert!(
w.contains("of the ") && w.contains("branch shapes probed"),
"a probe-derived verdict must count the shapes it probed: {}",
w
);
let w = branch_pattern_warning("{type}/{desc}", "gwm-cli", &default_branch_types()).expect("must warn");
assert!(
!w.contains("branch shapes probed") && w.contains("carries no `{issue}`"),
"a segment the pattern cannot supply at all is not a probe result and must not borrow its hedging: {}",
w
);
}
#[test]
fn an_ambiguous_pattern_is_reported_as_refused_not_as_lossy() {
let w = branch_pattern_warning("{type}/#{issue}{desc}", "gwm-cli", &default_branch_types())
.expect("adjacent placeholders must warn");
assert!(
w.contains("nothing between them") && w.contains("separate them with a literal"),
"the warning must say what is wrong and how to fix it: {}",
w
);
}
#[test]
fn the_documented_pattern_table_matches_reality() {
for pattern in [
"{type}/#{issue}-{desc}",
"{type}-{issue}-{desc}",
"{type}_{issue}_{desc}",
"{type}/{issue}-{desc}",
"{type}/#{issue}_{desc}",
"{repo}/{type}/#{issue}-{desc}",
"wt/{type}/#{issue}-{desc}",
"{type}/#{issue}-prefix-{desc}",
"{type}/#{issue}-{desc}-{repo}",
"{desc}/#{issue}-{type}",
"{type}/#{desc}-{issue}",
"{type}{issue}-{desc}",
"{issue}{type}-{desc}",
"{type}-{issue}9-{desc}",
] {
assert_eq!(
branch_pattern_warning(pattern, "gwm-cli", &default_branch_types()),
None,
"`{}` is documented as round-tripping",
pattern
);
}
for pattern in [
"{issue}{desc}",
"{desc}{issue}",
"{type}{desc}",
"{type}-{issue}9{desc}",
"{type}a{desc}",
"{desc}1{issue}",
"{desc}-{desc}",
] {
let w = branch_pattern_warning(pattern, "gwm-cli", &default_branch_types())
.unwrap_or_else(|| panic!("`{}` is documented as refused but did not warn", pattern));
assert!(
w.contains("nothing between them") || w.contains("could be read as part of") || w.contains("more than once"),
"`{}` is documented as refused by the compiler: {}",
pattern,
w
);
}
for (pattern, segment, frozen) in [
("feat/#{issue}-{desc}", "type", "feat"),
("{type}/#1-{desc}", "issue", "1"),
("{type}/#{issue}-fixed", "desc", "fixed"),
] {
let parser = BranchParser::compile(pattern, "gwm-cli", &default_branch_types()).expect("compiles");
assert_eq!(
parser.constants(),
&[(segment, frozen.to_string())][..],
"`{}` is documented as freezing {} to `{}`",
pattern,
segment,
frozen
);
let w = branch_pattern_warning(pattern, "gwm-cli", &default_branch_types())
.unwrap_or_else(|| panic!("`{}` is documented as losing what create asked for", pattern));
assert!(
w.contains(&format!("read back `{}`", segment)),
"`{}` is documented as losing the {} create was given: {}",
pattern,
segment,
w
);
}
for (pattern, token) in [
("{issue}-{desc}", "`{type}`"),
("{type}/{desc}", "`{issue}`"),
("{type}/#{issue}", "`{desc}`"),
] {
let w = branch_pattern_warning(pattern, "gwm-cli", &default_branch_types())
.unwrap_or_else(|| panic!("`{}` is documented as carrying no segment but did not warn", pattern));
assert!(
w.contains(&format!("carries no {}", token)),
"`{}` is documented as carrying no {}: {}",
pattern,
token,
w
);
}
let w = branch_pattern_warning(UNREADABLE, "gwm-cli", &default_branch_types())
.expect("a `~`-leading pattern is documented as unreadable");
assert!(w.contains("match nothing at all"), "got: {}", w);
}
#[test]
fn a_reordered_pattern_with_a_frozen_segment_is_never_wrong_and_never_quiet() {
let types = default_branch_types();
let slots = [("{type}", "feat"), ("{issue}", "42"), ("{desc}", "login")];
let orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
let separators = ["/", "-", "/#", "_"];
let mut wrong: Vec<String> = Vec::new();
let mut unstated: Vec<String> = Vec::new();
let mut checked = 0;
for order in orders {
for frozen in 0..3 {
for separator in separators {
let pattern = order
.iter()
.map(|&i| {
if i == frozen {
slots[i].1.to_string()
} else {
slots[i].0.to_string()
}
})
.collect::<Vec<_>>()
.join(separator);
let Ok(parser) = BranchParser::compile(&pattern, "gwm-cli", &types) else {
continue;
};
checked += 1;
let segment = ["type", "issue", "desc"][frozen];
let expected = slots[frozen].1;
let recovered = parser
.constants()
.iter()
.find(|(s, _)| *s == segment)
.map(|(_, v)| v.clone());
match recovered {
Some(value) if value != expected => wrong.push(format!(
"`{}` read {} as `{}`, not `{}`",
pattern, segment, value, expected
)),
None => {
let warning = branch_pattern_warning(&pattern, "gwm-cli", &types);
let names_it = warning.as_deref().is_some_and(|w| {
w.contains(&format!("`{{{}}}`", segment)) || w.contains(&format!("read back `{}`", segment))
});
if !names_it {
unstated.push(format!("`{}` loses {} silently: {:?}", pattern, segment, warning));
}
}
_ => {}
}
}
}
}
assert!(checked >= 60, "the family should be large, got {}", checked);
assert!(
wrong.is_empty(),
"a reordered pattern must never read a frozen segment back as the wrong value:\n{}",
wrong.join("\n")
);
assert!(
unstated.is_empty(),
"a frozen segment a reordered pattern cannot recover must be named by `branch_pattern_warning`, \
since that is the whole of what the user gets:\n{}",
unstated.join("\n")
);
}
#[test]
fn two_ways_to_read_the_same_value_are_not_an_ambiguity() {
for (pattern, segment, frozen) in [
("feat/feat/#{issue}-{desc}", "type", "feat"),
("{type}/#1/1-{desc}", "issue", "1"),
("{type}/#{issue}-fixed/fixed", "desc", "fixed"),
] {
let parser = BranchParser::compile(pattern, "gwm-cli", &default_branch_types()).expect("compiles");
assert_eq!(
parser.constants(),
&[(segment, frozen.to_string())][..],
"`{}` reads {} as `{}` whichever candidate is taken, so it is frozen, not ambiguous",
pattern,
segment,
frozen
);
}
let parser = BranchParser::compile("feat/fix/#{issue}-{desc}", "gwm-cli", &default_branch_types()).expect("compiles");
assert!(
parser.constants().is_empty(),
"`feat/fix/#{{issue}}-{{desc}}` names two different configured types, which is a real ambiguity"
);
let parser = BranchParser::compile("feat/#{issue}-fix/done", "gwm-cli", &default_branch_types()).expect("compiles");
assert_eq!(
parser.constants(),
&[("type", "feat".to_string())][..],
"the type is unanimous across both readings of `feat/#{{issue}}-fix/done`; only the description is ambiguous"
);
}
#[test]
fn an_unusable_branch_type_never_makes_the_default_pattern_look_broken() {
let invalid = vec![
BranchType {
name: "Feat".into(), description: String::new(),
},
BranchType {
name: "fix".into(),
description: String::new(),
},
];
assert_eq!(
branch_pattern_warning("{type}/#{issue}-{desc}", "gwm-cli", &invalid),
None
);
let all_invalid = vec![BranchType {
name: "Feat".into(),
description: String::new(),
}];
assert_eq!(
branch_pattern_warning("{type}-{issue}-{desc}", "gwm-cli", &all_invalid),
None
);
}
#[test]
fn control_characters_in_the_pattern_never_reach_the_terminal() {
const OSC52: &str = "\u{1b}]52;c;cHduZWQ=\u{7}";
let w = branch_pattern_warning(
&format!("{{issue}}{{desc}}{}", OSC52),
"gwm-cli",
&default_branch_types(),
)
.expect("adjacent placeholders must warn");
assert!(
!w.chars().any(|c| c.is_control()),
"no control character may survive the compile error: {:?}",
w
);
assert!(w.contains("{issue}{desc}"), "the value stays recognisable: {}", w);
let w = branch_pattern_warning("{type}\u{1b}[2J{issue}", "gwm-cli", &default_branch_types())
.expect("a pattern with no `{desc}` must warn");
assert!(
!w.chars().any(|c| c.is_control()),
"no control character may survive the missing-placeholder verdict: {:?}",
w
);
assert!(w.contains("{type}"), "the value stays recognisable: {}", w);
let w = branch_pattern_warning(
&format!("~/{{type}}/#{{issue}}-{{desc}}{}", OSC52),
"gwm-cli",
&default_branch_types(),
)
.expect("a `~`-leading pattern must warn");
assert!(
w.contains("match nothing at all") && !w.chars().any(|c| c.is_control()),
"the formatted example is echoed too: {:?}",
w
);
}
#[test]
fn the_consumer_mapping_matches_the_call_sites() {
let w =
branch_pattern_warning(UNREADABLE, "gwm-cli", &default_branch_types()).expect("an unreadable pattern must warn");
assert!(
w.contains("PR/MR detection is unaffected"),
"PR detection survives an unreadable pattern — do not claim otherwise: {}",
w
);
assert!(
w.contains("`gwm pr` template selection and placeholders"),
"`gwm pr` parses the branch and must be named as broken: {}",
w
);
assert!(
w.contains("remove/bootstrap hook placeholders") && !w.contains("lifecycle hook placeholders"),
"`gwm create` passes the original BranchSpec to its hooks — only remove/bootstrap re-parse: {}",
w
);
}
#[test]
fn a_freeform_name_is_kept_as_typed_when_it_is_already_safe() {
let n = WorktreeName::freeform("spike-redis").expect("a plain slug is valid");
let cfg = WorktreeConfig::default();
assert_eq!(n.branch_name(&cfg, "gwm-cli").unwrap(), "spike-redis");
assert_eq!(n.worktree_dirname(&cfg, "gwm-cli").unwrap(), "spike-redis");
}
#[test]
fn a_freeform_name_is_not_held_to_the_desc_convention() {
for name in ["Spike_Redis", "2026.07.27", "réécriture", "WIP"] {
assert!(
WorktreeName::freeform(name).is_ok(),
"`{}` is a legal git ref and must be accepted",
name
);
}
}
#[test]
fn patterns_do_not_apply_to_a_freeform_name_but_base_still_does() {
let cfg = WorktreeConfig {
base: "/tmp/{repo}".into(),
path_pattern: "{type}-{issue}-{desc}".into(),
branch_pattern: "{type}/#{issue}-{desc}".into(),
};
let n = WorktreeName::freeform("spike-redis").unwrap();
assert_eq!(n.branch_name(&cfg, "r").unwrap(), "spike-redis");
let p = n.worktree_path(&cfg, "r", std::path::Path::new("/repos/r")).unwrap();
assert_eq!(p, std::path::Path::new("/tmp/r/spike-redis"));
}
#[test]
fn a_base_written_with_the_structured_placeholders_is_refused_not_left_literal() {
for base in ["/srv/{type}", "/srv/{repo}-{issue}", "{home}/wt/{desc}"] {
let cfg = WorktreeConfig {
base: base.into(),
..WorktreeConfig::default()
};
let n = WorktreeName::freeform("spike-redis").unwrap();
let err = n
.worktree_path(&cfg, "r", std::path::Path::new("/repos/r"))
.expect_err(&format!("`{}` has no value to resolve for a free-form name", base));
let msg = format!("{}", err);
assert!(msg.contains("base"), "the message must point at worktree.base: {}", msg);
}
}
#[test]
fn a_slash_survives_in_the_branch_and_flattens_in_the_directory() {
let n = WorktreeName::freeform("spike/redis").expect("a slash is a legal ref");
let cfg = WorktreeConfig::default();
assert_eq!(n.branch_name(&cfg, "r").unwrap(), "spike/redis");
assert_eq!(n.worktree_dirname(&cfg, "r").unwrap(), "spike-redis");
}
#[test]
fn a_freeform_name_that_git_or_the_filesystem_would_refuse_is_rejected() {
for bad in [
"", " ", "..", "a..b", "-leading", "trailing.", "has space", "tilde~1", "caret^", "colon:", "quest?", "star*", "brack[et", "back\\slash", "at@{brace", "ends.lock", "ctrl\u{7}x", "/leading", "trailing/", "double//slash",
] {
assert!(
WorktreeName::freeform(bad).is_err(),
"`{}` must be rejected as a worktree name",
bad
);
}
}
#[test]
fn surrounding_whitespace_is_refused_rather_than_silently_stripped() {
for bad in [" spike", "spike ", "\tspike", "spike\n"] {
assert!(
WorktreeName::freeform(bad).is_err(),
"`{:?}` must be refused, not trimmed into a different branch",
bad
);
}
}
#[test]
fn a_name_that_would_overflow_a_path_component_is_refused_before_anything_is_created() {
let long = format!("{}/{}", "a".repeat(130), "b".repeat(130));
assert!(
git2::Reference::is_valid_name(&format!("refs/heads/{}", long)),
"precondition: git accepts this ref, so only our own check can stop it"
);
assert!(
WorktreeName::freeform(&long).is_err(),
"a 261-byte directory name must be refused up front"
);
let edge = format!("{}/{}", "a".repeat(251), "b".repeat(3));
assert_eq!(edge.len(), 255);
assert!(WorktreeName::freeform(&edge).is_ok(), "255 bytes is legal");
assert!(
WorktreeName::freeform(&format!("{}b", edge)).is_err(),
"256 bytes is not"
);
}
#[test]
fn the_final_segment_leaves_room_for_git_s_lock_file() {
assert!(
WorktreeName::freeform(&"a".repeat(250)).is_ok(),
"250 + `.lock` is exactly 255 — legal"
);
assert!(
WorktreeName::freeform(&"a".repeat(251)).is_err(),
"251 + `.lock` overflows the component git has to create first"
);
let front_heavy = format!("{}/{}", "a".repeat(251), "b".repeat(3));
assert!(
WorktreeName::freeform(&front_heavy).is_ok(),
"a 251-byte segment is fine when it is not the one carrying `.lock`"
);
}
#[test]
fn a_name_git_refuses_as_a_branch_is_refused_even_when_the_ref_syntax_is_legal() {
assert!(
git2::Reference::is_valid_name("refs/heads/HEAD"),
"precondition: the ref-level oracle lets `HEAD` through, so only the branch-level one can stop it"
);
assert!(
!git2::Branch::name_is_valid("HEAD").unwrap(),
"precondition: the branch-level oracle is the one that refuses it"
);
assert!(WorktreeName::freeform("HEAD").is_err(), "`HEAD` is not a branch name");
}
#[test]
fn a_name_that_looks_like_a_placeholder_is_refused() {
for bad in ["spike-{issue}", "{repo}-spike", "{branch}", "closing}brace"] {
assert!(
WorktreeName::freeform(bad).is_err(),
"`{}` would be re-substituted during hook expansion",
bad
);
}
}
#[test]
fn a_name_carrying_a_character_windows_forbids_in_a_path_is_refused() {
for bad in ["spike<x", "spike>x", "spike\"x", "spike|x"] {
assert!(
git2::Branch::name_is_valid(bad).unwrap(),
"precondition: git accepts `{}`, so only our own check can stop it",
bad
);
assert!(
WorktreeName::freeform(bad).is_err(),
"`{}` cannot become a directory on Windows and must be refused up front",
bad
);
}
}
#[test]
fn a_name_whose_segment_is_a_reserved_windows_device_is_refused() {
for bad in [
"CON",
"con",
"CoN",
"PRN",
"AUX",
"NUL",
"COM1",
"COM9",
"LPT1",
"LPT9", "CON.txt",
"NUL.tar.gz",
"com4.log", "COM\u{b9}",
"COM\u{b2}",
"COM\u{b3}",
"LPT\u{b9}", "spike/CON",
"CON/spike", ] {
assert!(
git2::Branch::name_is_valid(bad).unwrap(),
"precondition: git accepts `{}`, so only our own check can stop it",
bad
);
assert!(
WorktreeName::freeform(bad).is_err(),
"`{}` is a reserved Windows device name and must be refused up front",
bad
);
}
}
#[test]
fn the_windows_rule_stops_where_the_reserved_list_stops() {
for good in [
"COM0",
"LPT0",
"CONX",
"CON-x",
"spike-CON",
"console",
"x.CON",
"COM10",
] {
assert!(
WorktreeName::freeform(good).is_ok(),
"`{}` is not a reserved device name and must stay usable",
good
);
}
}
#[test]
fn the_trailing_space_rule_is_left_to_git_in_every_position() {
for bad in ["spike ", " spike", "spi ke", "foo /bar", "foo/ bar"] {
assert!(
!git2::Branch::name_is_valid(bad).unwrap_or(false),
"`{}` is refused by git itself, which is why gwm carries no rule for it",
bad
);
assert!(WorktreeName::freeform(bad).is_err(), "`{}` must still be refused", bad);
}
}
#[test]
fn a_trailing_period_is_refused_on_every_segment_not_just_the_last() {
assert!(
!git2::Branch::name_is_valid("spike.").unwrap_or(false),
"precondition: the final segment is git's own rule"
);
for bad in ["foo./bar", "a./b./c", "spike./x"] {
assert!(
git2::Branch::name_is_valid(bad).unwrap(),
"precondition: git accepts `{}`, so only our own check can stop it",
bad
);
assert!(
WorktreeName::freeform(bad).is_err(),
"`{}` needs a directory ending in `.`, which Windows refuses",
bad
);
}
assert!(
WorktreeName::freeform("v1.2/spike").is_ok(),
"a period inside a segment is not a trailing one and must stay legal"
);
}
#[test]
fn the_rejection_names_the_offending_value() {
let err = WorktreeName::freeform("has space").unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("has space"), "the message must quote the input: {}", msg);
}
fn round_trip(pattern: &str, type_: &str, issue: &str, desc: &str) -> (String, Option<(String, String, String)>) {
let cfg = WorktreeConfig {
branch_pattern: pattern.into(),
..Default::default()
};
let spec = BranchSpec::new(type_, issue, desc).expect("the probe triple is valid");
let branch = spec.branch_name(&cfg, "gwm-cli").expect("the pattern expands");
let parser = BranchParser::compile(pattern, "gwm-cli", &default_branch_types()).expect("the pattern compiles");
let back = parser
.parse(&branch)
.map(|s| (s.type_.clone(), s.issue.clone(), s.desc.clone()));
(branch, back)
}
#[test]
fn every_plausible_convention_reads_back_what_it_wrote() {
for pattern in [
"{type}/#{issue}-{desc}", "{type}-{issue}-{desc}",
"{type}_{issue}_{desc}",
"{type}/{issue}-{desc}",
"{repo}/{type}/#{issue}-{desc}",
] {
let (branch, back) = round_trip(pattern, "feat", "417", "derive-branch-parser");
assert_eq!(
back,
Some(("feat".into(), "417".into(), "derive-branch-parser".into())),
"pattern `{}` wrote `{}` and could not read it back",
pattern,
branch
);
}
}
#[test]
fn a_pattern_that_omits_a_token_reports_the_segments_it_does_carry() {
let (branch, back) = round_trip("{type}/{desc}", "fix", "9", "flaky-test");
assert_eq!(branch, "fix/flaky-test");
assert_eq!(back, Some(("fix".into(), String::new(), "flaky-test".into())));
let (branch, back) = round_trip("{issue}-{desc}", "feat", "42", "no-type-here");
assert_eq!(branch, "42-no-type-here");
assert_eq!(back, Some((String::new(), "42".into(), "no-type-here".into())));
let (branch, back) = round_trip("feature/{issue}-{desc}", "feat", "42", "literal-prefix");
assert_eq!(branch, "feature/42-literal-prefix");
assert_eq!(back, Some((String::new(), "42".into(), "literal-prefix".into())));
}
#[test]
fn a_separator_the_left_token_could_itself_contain_is_not_an_obstacle() {
let (branch, back) = round_trip("{desc}-{issue}", "feat", "42", "user-auth");
assert_eq!(branch, "user-auth-42");
assert_eq!(back, Some((String::new(), "42".into(), "user-auth".into())));
let (_, back) = round_trip("{desc}-{issue}", "feat", "2", "spike-1");
assert_eq!(back, Some((String::new(), "2".into(), "spike-1".into())));
}
#[test]
fn two_tokens_with_nothing_between_them_are_refused_at_compile_time() {
for pattern in ["{issue}{desc}", "{desc}{issue}", "{type}{desc}"] {
let err = BranchParser::compile(pattern, "gwm-cli", &default_branch_types())
.expect_err(&format!("`{}` must be refused, not compiled", pattern));
let msg = format!("{}", err);
assert!(
msg.contains("nothing") && msg.contains(pattern),
"the message must quote the pattern and say what is missing: {}",
msg
);
}
let (branch, back) = round_trip("{type}{issue}-{desc}", "feat", "42", "9-my");
assert_eq!(branch, "feat42-9-my");
assert_eq!(back, Some(("feat".into(), "42".into(), "9-my".into())));
let (branch, back) = round_trip("{issue}{type}-{desc}", "feat", "42", "x9");
assert_eq!(branch, "42feat-x9");
assert_eq!(back, Some(("feat".into(), "42".into(), "x9".into())));
}
#[test]
fn the_same_token_twice_is_refused_rather_than_compiled_into_a_second_group() {
let err = BranchParser::compile("{desc}-{desc}", "gwm-cli", &default_branch_types())
.expect_err("a repeated token must be refused");
let msg = format!("{}", err);
assert!(msg.contains("more than once"), "unexpected message: {}", msg);
}
#[test]
fn a_pattern_that_cannot_be_compiled_reads_nothing_rather_than_the_default_shape() {
let mut config = gwm::config::Config::default();
config.worktree.branch_pattern = "{desc}{issue}".into();
let parser = BranchParser::from_config(&config, "gwm-cli");
assert!(parser.parse("feat/#1-x").is_none());
assert!(parser.parse("x1").is_none());
}
#[test]
fn the_builtin_parser_still_reads_the_canonical_shape() {
let spec = BranchParser::builtin()
.parse("feat/#417-derive-branch-parser")
.expect("the canonical shape parses");
assert_eq!(spec.type_, "feat");
assert_eq!(spec.issue, "417");
assert_eq!(spec.desc, "derive-branch-parser");
assert!(BranchParser::builtin().parse("random").is_none());
}
#[test]
fn the_compiler_handles_every_token_the_formatter_substitutes() {
let src = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/config.rs"))
.expect("read src/config.rs")
.replace("\r\n", "\n");
let body = src
.split_once("pub fn expand_placeholders(")
.expect("expand_placeholders is still named that")
.1;
let body = body.split_once("\n}\n").expect("the function has a body").0;
let mut found: Vec<String> = Vec::new();
let mut rest = body;
while let Some(open) = rest.find("\"{") {
rest = &rest[open + 1..];
let Some(close) = rest.find("}\"") else { break };
found.push(rest[..close + 1].to_string());
rest = &rest[close + 1..];
}
found.sort();
found.dedup();
assert!(
found.len() >= 5,
"the token scan found only {:?} — the extraction broke, not the compiler",
found
);
const HANDLED: [&str; 5] = ["{home}", "{repo}", "{type}", "{issue}", "{desc}"];
const LITERAL_ON_BRANCH_PATH: [&str; 2] = ["{repo_path}", "{repo_parent}"];
for token in &found {
assert!(
HANDLED.contains(&token.as_str()) || LITERAL_ON_BRANCH_PATH.contains(&token.as_str()),
"`{}` is substituted by expand_placeholders but BranchParser::compile does not know it — \
it would be matched as a literal while the formatter replaced it. Add it to the compiler \
(and to this list), or explain why the branch path leaves it literal.",
token
);
}
}
#[test]
fn a_worktree_spec_takes_from_the_path_what_the_branch_cannot_carry() {
let mut config = Config::default();
config.worktree.branch_pattern = "feat/#{issue}-{desc}".into();
let spec = worktree_spec(&config, "gwm-cli", "feat/#42-x", Some("fix-42-x")).expect("reads the worktree");
assert_eq!(
(spec.type_.as_str(), spec.issue.as_str(), spec.desc.as_str()),
("fix", "42", "x"),
"the type the worktree was created with survives in its directory"
);
let spec = worktree_spec(&config, "gwm-cli", "feat/#42-x", None).expect("reads the branch");
assert_eq!(spec.type_, "feat");
}
#[test]
fn a_segment_the_branch_writes_is_never_overridden_by_the_path() {
let config = Config::default(); let spec = worktree_spec(&config, "gwm-cli", "feat/#42-x", Some("chore-9-something-else")).expect("reads");
assert_eq!(
(spec.type_.as_str(), spec.issue.as_str(), spec.desc.as_str()),
("feat", "42", "x")
);
}
#[test]
fn a_path_pattern_that_says_nothing_leaves_the_branch_reading_alone() {
let mut config = Config::default();
config.worktree.branch_pattern = "feat/#{issue}-{desc}".into();
for dirname in [
"not-shaped-like-the-pattern-at-all/x", "", ] {
let spec = worktree_spec(&config, "gwm-cli", "feat/#42-x", Some(dirname)).expect("reads the branch");
assert_eq!(spec.type_, "feat", "`{}` must not disturb the reading", dirname);
}
config.worktree.path_pattern = "{issue}{desc}".into(); let spec = worktree_spec(&config, "gwm-cli", "feat/#42-x", Some("fix-42-x")).expect("reads the branch");
assert_eq!(spec.type_, "feat");
}
#[test]
fn every_pattern_1_5_0_read_is_read_the_same_way() {
let oracle = regex::Regex::new(r"^([a-z]+)/#(\d+)-([a-z0-9-]+)$").expect("the 1.5.0 regex compiles");
let types = default_branch_types();
const PROBE: (&str, &str, &str) = ("chore", "42", "x");
let mut checked = 0usize;
let mut broken: Vec<String> = Vec::new();
for a in ["{type}", "feat"] {
for b in ["{issue}", "1"] {
for c in ["{desc}", "fix", "fixed--desc", "fixed-", "--fix", "-"] {
let pattern = format!("{}/#{}-{}", a, b, c);
let cfg = WorktreeConfig {
branch_pattern: pattern.clone(),
..WorktreeConfig::default()
};
let spec = BranchSpec::new_with_types(PROBE.0, PROBE.1, PROBE.2, &types).expect("a valid triple");
let name = spec.branch_name(&cfg, "gwm-cli").expect("the formatter writes it");
let Some(old) = oracle.captures(&name) else { continue };
let parser =
BranchParser::compile(&pattern, "gwm-cli", &types).unwrap_or_else(|e| panic!("`{}`: {}", pattern, e));
let read = parser
.parse(&name)
.unwrap_or_else(|| panic!("`{}` wrote `{}`, which 1.5.0 read and this does not", pattern, name));
let now = (read.type_.clone(), read.issue.clone(), read.desc.clone());
let then = (
old.get(1).unwrap().as_str().to_string(),
old.get(2).unwrap().as_str().to_string(),
old.get(3).unwrap().as_str().to_string(),
);
if then.2.starts_with('-') {
assert_eq!(
now.2,
then.2.trim_start_matches('-'),
"`{}` wrote `{}`: a leading dash is dropped from the description, not the description",
pattern,
name
);
checked += 1;
continue;
}
if now != then {
broken.push(format!(
"`{}` wrote `{}`: 1.5.0 read {:?}, this reads {:?}",
pattern, name, then, now
));
}
checked += 1;
}
}
}
assert_eq!(
checked, 24,
"every pattern in the family must be inside what 1.5.0 could read"
);
assert!(
broken.is_empty(),
"patterns 1.5.0 read that this reads differently:\n {}",
broken.join("\n ")
);
}
#[test]
fn a_pattern_whose_expansion_carries_a_token_now_round_trips() {
let types = default_branch_types();
for (pattern, repo) in [
("{repo}/#{issue}-{desc}", "{type}"),
("{{repo}}/#{issue}-{desc}", "type"),
] {
let written =
gwm::config::expand_placeholders(pattern, repo, Some("feat"), Some("42"), Some("x"), None).expect("formats");
assert_eq!(
written, "{type}/#42-x",
"`{}` in a repo called `{}` must substitute exactly once",
pattern, repo
);
let parser = BranchParser::compile(pattern, repo, &types)
.unwrap_or_else(|e| panic!("`{}` must no longer be refused: {}", pattern, e));
let back = parser
.parse(&written)
.expect("the parser reads what the formatter wrote");
assert_eq!((back.issue.as_str(), back.desc.as_str()), ("42", "x"));
assert_eq!(
back.type_, "",
"the pattern writes no type — `{{type}}` is literal text here, not a placeholder"
);
}
let parser = BranchParser::compile("{repo}/#{issue}-{desc}", "gwm-cli", &types).expect("compiles");
assert_eq!(
parser.parse("gwm-cli/#42-x").map(|s| (s.issue, s.desc)),
Some(("42".into(), "x".into()))
);
}
#[test]
fn the_compiler_finds_placeholders_where_the_formatter_substitutes_them() {
let types = default_branch_types();
for (pattern, expected_name) in [
("{{type}/#{issue}-{desc}", "{feat/#42-x"),
("{type{issue}}-{desc}", "{type42}-x"),
("{type}/#{issue}-{desc}}", "feat/#42-x}"),
("{foo}/{type}/#{issue}-{desc}", "{foo}/feat/#42-x"),
] {
let cfg = WorktreeConfig {
branch_pattern: pattern.into(),
..WorktreeConfig::default()
};
let spec = BranchSpec::new_with_types("feat", "42", "x", &types).expect("a valid triple");
let name = spec.branch_name(&cfg, "gwm-cli").expect("the formatter writes it");
assert_eq!(
name, expected_name,
"`{}` does not write what the test assumes",
pattern
);
let parser = BranchParser::compile(pattern, "gwm-cli", &types).unwrap_or_else(|e| panic!("`{}`: {}", pattern, e));
let read = parser
.parse(&name)
.unwrap_or_else(|| panic!("`{}` wrote `{}` and cannot read it back", pattern, name));
for (token, written, got) in [
("{type}", "feat", &read.type_),
("{issue}", "42", &read.issue),
("{desc}", "x", &read.desc),
] {
if pattern.contains(token) {
assert_eq!(
got, written,
"`{}` wrote `{}` and read {} back as `{}`",
pattern, name, token, got
);
}
}
}
}
const V1_5_0_PARSED: [(&str, &str, &str, &str); 4] = [
("{type}/#{issue}-{desc}", "feat", "42", "my-desc"),
("feat/#{issue}-{desc}", "feat", "42", "my-desc"),
("{type}/#1-{desc}", "feat", "1", "my-desc"),
("{type}/#{issue}-fixed", "feat", "42", "fixed"),
];
#[test]
fn every_branch_1_5_0_could_read_is_still_read_the_same_way() {
for (pattern, want_type, want_issue, want_desc) in V1_5_0_PARSED {
let (branch, back) = round_trip(pattern, "feat", "42", "my-desc");
assert_eq!(
back,
Some((want_type.into(), want_issue.into(), want_desc.into())),
"`{}` wrote `{}`; gwm 1.5.0 read it as ({}, {}, {}) and this must not regress",
pattern,
branch,
want_type,
want_issue,
want_desc
);
}
}
#[test]
fn the_two_patterns_1_5_0_read_wrongly_are_read_correctly_now() {
let (_, back) = round_trip("{type}/#{issue}-prefix-{desc}", "feat", "42", "my-desc");
assert_eq!(back, Some(("feat".into(), "42".into(), "my-desc".into())));
let (_, back) = round_trip("{type}/#{issue}-{desc}-{repo}", "feat", "42", "my-desc");
assert_eq!(back, Some(("feat".into(), "42".into(), "my-desc".into())));
}
#[test]
fn a_branch_type_the_repo_no_longer_declares_is_still_read() {
let only_feat = vec![BranchType {
name: "feat".into(),
description: "the only configured type".into(),
}];
let parser = BranchParser::compile("{type}/#{issue}-{desc}", "gwm-cli", &only_feat).expect("compiles");
let spec = parser.parse("wip/#1-x").expect("an unconfigured type still parses");
assert_eq!(spec.type_, "wip");
assert_eq!(spec.issue, "1");
}
#[test]
fn a_frozen_segment_is_recovered_from_the_literal_that_freezes_it() {
let types = default_branch_types();
let c = |p: &str| {
BranchParser::compile(p, "gwm-cli", &types)
.expect("compiles")
.constants()
.iter()
.map(|(seg, value)| (*seg, value.clone()))
.collect::<Vec<_>>()
};
assert_eq!(c("feat/#{issue}-{desc}"), vec![("type", "feat".into())]);
assert_eq!(c("{type}/#1-{desc}"), vec![("issue", "1".into())]);
assert_eq!(c("{type}/#{issue}-fixed"), vec![("desc", "fixed".into())]);
assert_eq!(c("{type}/#{issue}-my-fix"), vec![("desc", "my-fix".into())]);
assert_eq!(
c("feat/#1-fixed"),
vec![("type", "feat".into()), ("issue", "1".into()), ("desc", "fixed".into())]
);
assert!(c("{type}/#{issue}-{desc}").is_empty());
}
#[test]
fn a_namespace_literal_is_not_mistaken_for_a_branch_type() {
let types = default_branch_types();
for pattern in [
"feature/{issue}-{desc}", "wt/{issue}-{desc}",
"{repo}/{issue}-{desc}", ] {
let parser = BranchParser::compile(pattern, "gwm-cli", &types).expect("compiles");
assert!(
!parser.reads_segment("type"),
"`{}` must not invent a branch type",
pattern
);
}
let parser = BranchParser::compile("feat/fix-{issue}-{desc}", "gwm-cli", &types).expect("compiles");
assert!(!parser.reads_segment("type"));
}
#[test]
fn a_repo_named_after_a_branch_type_does_not_type_its_branches() {
let types = default_branch_types();
let parser = BranchParser::compile("{repo}/#{issue}-{desc}", "docs", &types).expect("compiles");
assert!(!parser.reads_segment("type"));
let spec = parser.parse("docs/#42-x").expect("parses");
assert_eq!(spec.type_, "");
}
const LEGAL: [&str; 20] = [
"{desc}-{issue}",
"{type}/#{issue}-{desc}",
"{type}-{issue}-{desc}",
"{type}_{issue}_{desc}",
"{type}/{issue}-{desc}",
"{type}/#{issue}_{desc}",
"{repo}/{type}/#{issue}-{desc}",
"wt/{type}/#{issue}-{desc}",
"{type}/#{issue}-prefix-{desc}",
"{type}/#{issue}-{desc}-{repo}",
"{desc}/#{issue}-{type}",
"{type}/#{desc}-{issue}",
"feat/#{issue}-{desc}",
"{type}/#1-{desc}",
"{type}/#{issue}-fixed",
"{type}{issue}-{desc}",
"{issue}{type}-{desc}",
"{type}{issue}",
"{type}-{issue}9-{desc}",
"{issue}9-{desc}",
];
const REFUSED: [(&str, &str); 8] = [
("{issue}{desc}", "nothing between them"),
("{desc}{issue}", "nothing between them"),
("{type}{desc}", "nothing between them"),
("{desc}{type}", "nothing between them"),
("{type}-{issue}9{desc}", "could be read as part of"),
("{type}a{desc}", "could be read as part of"),
("{desc}1{issue}", "could be read as part of"),
("{desc}-{desc}", "more than once"),
];
const TYPES: [&str; 2] = ["feat", "fix"];
const ISSUES: [&str; 4] = ["1", "4", "42", "429"];
const DESCS: [&str; 10] = ["a", "foo", "a-b", "9-my", "19x", "2-a-b", "x9", "fix", "bar", "b9c"];
#[test]
fn every_legal_pattern_round_trips() {
let types = default_branch_types();
for pattern in LEGAL {
let parser = BranchParser::compile(pattern, "gwm-cli", &types)
.unwrap_or_else(|e| panic!("`{}` is a legal pattern and must compile: {}", pattern, e));
let cfg = WorktreeConfig {
branch_pattern: pattern.into(),
..WorktreeConfig::default()
};
for type_ in TYPES {
for issue in ISSUES {
for desc in DESCS {
let spec = BranchSpec::new_with_types(type_, issue, desc, &types).expect("a valid triple");
let name = spec.branch_name(&cfg, "gwm-cli").expect("the formatter writes it");
let read = parser
.parse(&name)
.unwrap_or_else(|| panic!("`{}` wrote `{}` and cannot read it back", pattern, name));
for (token, written, got) in [
("{type}", &spec.type_, &read.type_),
("{issue}", &spec.issue, &read.issue),
("{desc}", &spec.desc, &read.desc),
] {
if pattern.contains(token) {
assert_eq!(
written,
got,
"`{}` wrote `{}` from {:?} and read {} back as `{}`",
pattern,
name,
(type_, issue, desc),
token,
got
);
}
}
}
}
}
}
}
#[test]
fn every_refused_pattern_is_refused_for_the_stated_reason() {
for (pattern, phrase) in REFUSED {
let err = BranchParser::compile(pattern, "gwm-cli", &default_branch_types())
.map(|_| String::new())
.unwrap_or_else(|e| e.to_string());
assert!(!err.is_empty(), "`{}` must be refused, not compiled", pattern);
assert!(
err.contains(phrase),
"`{}` must be refused for the stated reason (`{}`): {}",
pattern,
phrase,
err
);
}
}
#[test]
fn the_ambiguity_rule_accepts_exactly_the_patterns_that_round_trip() {
const GROUPS: [(&str, &str); 3] = [
("{type}", r"(?P<type>[a-z]+)"),
("{issue}", r"(?P<issue>\d+)"),
("{desc}", r"(?P<desc>[a-z0-9][a-z0-9-]*)"),
];
const SEPS: [&str; 8] = ["", "-", "/", "#", "_", "9", "a", "9-"];
let types = default_branch_types();
let mut orders: Vec<Vec<usize>> = Vec::new();
for a in 0..3 {
for b in 0..3 {
if a == b {
continue;
}
orders.push(vec![a, b]);
for c in 0..3 {
if c != a && c != b {
orders.push(vec![a, b, c]);
}
}
}
}
let mut checked = 0usize;
for order in &orders {
for seps in separator_tuples(order.len() - 1, &SEPS) {
let mut pattern = String::new();
let mut oracle = String::from("^");
for (position, &segment) in order.iter().enumerate() {
if position > 0 {
pattern.push_str(seps[position - 1]);
oracle.push_str(®ex::escape(seps[position - 1]));
}
pattern.push_str(GROUPS[segment].0);
oracle.push_str(GROUPS[segment].1);
}
oracle.push('$');
let oracle = regex::Regex::new(&oracle).expect("the oracle regex compiles");
let round_trips = round_trips_every_value(&pattern, &oracle);
let accepted = BranchParser::compile(&pattern, "gwm-cli", &types).is_ok();
assert_eq!(
accepted, round_trips,
"`{}` round-trips: {}, but the compiler accepts it: {}",
pattern, round_trips, accepted
);
checked += 1;
}
}
assert_eq!(checked, 6 * SEPS.len() + 6 * SEPS.len() * SEPS.len());
}
fn separator_tuples(n: usize, pool: &'static [&'static str]) -> Vec<Vec<&'static str>> {
let mut out: Vec<Vec<&'static str>> = vec![Vec::new()];
for _ in 0..n {
out = out
.into_iter()
.flat_map(|prefix| {
pool.iter().map(move |sep| {
let mut next = prefix.clone();
next.push(sep);
next
})
})
.collect();
}
out
}
fn values(alphabet: &[char], max: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut frontier: Vec<String> = vec![String::new()];
for _ in 0..max {
frontier = frontier
.iter()
.flat_map(|prefix| {
alphabet.iter().map(move |c| {
let mut next = prefix.clone();
next.push(*c);
next
})
})
.collect();
out.extend(frontier.iter().cloned());
}
out
}
fn round_trips_every_value(pattern: &str, oracle: ®ex::Regex) -> bool {
for type_ in values(&['a', 'b'], 2) {
for issue in values(&['9', '1'], 2) {
for desc in values(&['a', '9', '-'], 3) {
if !DESC_SHAPE.is_match(&desc) {
continue;
}
let name = gwm::config::expand_placeholders(pattern, "", Some(&type_), Some(&issue), Some(&desc), None)
.expect("the formatter writes it");
let Some(cap) = oracle.captures(&name) else {
return false;
};
for (token, written) in [("type", &type_), ("issue", &issue), ("desc", &desc)] {
if pattern.contains(&format!("{{{}}}", token)) && cap.name(token).map(|m| m.as_str()) != Some(written) {
return false;
}
}
}
}
}
true
}
static DESC_SHAPE: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"^[a-z0-9][a-z0-9-]*$").unwrap());
#[test]
fn a_placeholder_between_two_literals_does_not_fuse_them() {
let types = default_branch_types();
for pattern in ["1{type}2-{desc}", "1{repo}2/{type}-{desc}", "1{home}2/{type}-{desc}"] {
let parser = BranchParser::compile(pattern, "x", &types).unwrap_or_else(|e| panic!("`{}`: {}", pattern, e));
let cfg = WorktreeConfig {
branch_pattern: pattern.into(),
..WorktreeConfig::default()
};
let name = BranchSpec::new_with_types("feat", "42", "foo", &types)
.expect("a valid triple")
.branch_name(&cfg, "x")
.expect("the formatter writes it");
for (segment, value) in parser.constants() {
assert!(
name.contains(value.as_str()),
"`{}` froze {} as `{}`, which no branch it writes contains — `{}` does not",
pattern,
segment,
value,
name
);
}
}
let parser = BranchParser::compile("1{type}2-{desc}", "x", &types).expect("compiles");
assert_eq!(
parser.constants().iter().find(|(segment, _)| *segment == "issue"),
Some(&("issue", "2".to_string())),
"the issue must be the `2` the pattern actually writes, never the fused `12`"
);
}
#[test]
fn the_editable_segments_come_back_in_the_order_the_pattern_writes_them() {
assert_eq!(
gwm::naming::editable_segments(&["{type}/#{issue}-{desc}"]),
["type", "issue", "desc"],
"the canonical pattern keeps the canonical order"
);
assert_eq!(
gwm::naming::editable_segments(&["{desc}-{issue}"]),
["desc", "issue"],
"a pattern that leads with the description is read in its own order"
);
assert_eq!(
gwm::naming::editable_segments(&["{type}/{desc}"]),
["type", "desc"],
"a pattern that writes no issue number must not ask for one"
);
assert!(
gwm::naming::editable_segments(&["wip"]).is_empty(),
"a pattern that is pure literal asks the user for nothing"
);
}
#[test]
fn a_segment_only_base_carries_is_still_asked_for() {
assert_eq!(
gwm::naming::editable_segments(&["{type}/{desc}", "{type}-{desc}", "{home}/wt/{issue}"]),
["type", "desc", "issue"],
"base carries the issue, so the form must still collect it"
);
}
#[test]
fn a_token_repeated_across_or_within_patterns_yields_one_field() {
assert_eq!(
gwm::naming::editable_segments(&["{type}/{desc}-{desc}", "{type}-{desc}"]),
["type", "desc"]
);
}
#[test]
fn every_substituted_token_is_either_context_resolved_or_a_form_field() {
let src = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/config.rs"))
.expect("read src/config.rs")
.replace("\r\n", "\n");
let body = src
.split_once("pub fn expand_placeholders(")
.expect("expand_placeholders is still named that")
.1;
let body = body.split_once("\n}\n").expect("the function has a body").0;
let mut found: Vec<String> = Vec::new();
let mut rest = body;
while let Some(open) = rest.find("\"{") {
rest = &rest[open + 1..];
let Some(close) = rest.find("}\"") else { break };
found.push(rest[..close + 1].to_string());
rest = &rest[close + 1..];
}
found.sort();
found.dedup();
assert!(
found.len() >= 5,
"the token scan found only {:?} — the extraction broke, not the classification",
found
);
const FROM_CONTEXT: [&str; 4] = ["{home}", "{repo}", "{repo_path}", "{repo_parent}"];
for token in &found {
let segment = token.trim_start_matches('{').trim_end_matches('}');
let is_field = !gwm::naming::editable_segments(&[token.as_str()]).is_empty();
assert!(
FROM_CONTEXT.contains(&token.as_str()) || is_field,
"`{}` is substituted by expand_placeholders but is neither resolved from context nor \
collected by the create form — a pattern using it would expand it to nothing. Add `{}` \
to `editable_segments` (and give the form a field for it), or to FROM_CONTEXT here.",
token,
segment
);
}
}
#[test]
fn the_field_set_reads_the_pattern_as_written() {
assert_eq!(
gwm::config::expand_placeholders(
"{repo}/{desc}",
"api-{type}",
Some("fix"),
Some("42"),
Some("foo"),
None
)
.unwrap(),
"api-{type}/foo",
"the repo name is data, so the `{{type}}` inside it is never substituted"
);
assert_eq!(
gwm::naming::editable_segments(&["{repo}/{desc}"]),
["desc"],
"so the form must not ask for a type the pattern does not write"
);
assert_eq!(
gwm::naming::editable_segments(&["{desc}", "{repo}-{desc}", "~/wt"]),
["desc"]
);
assert_eq!(
gwm::naming::editable_segments(&["{desc}", "{desc}", "~/wt/{repo}"]),
["desc"]
);
assert_eq!(
gwm::naming::editable_segments(&["{repo}/{type}/#{issue}-{desc}"]),
["type", "issue", "desc"]
);
}