use crate::config::{expand_placeholders, BranchType, WorktreeConfig};
use crate::error::{GwmError, Result};
use regex::Regex;
use std::sync::LazyLock;
static ISSUE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d+$").expect("static ISSUE_RE compiles"));
static DESC_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]*$").expect("static DESC_RE compiles"));
const TYPE_GROUP: &str = r"(?P<type>[a-z]+)";
const ISSUE_GROUP: &str = r"(?P<issue>\d+)";
const DESC_GROUP: &str = r"(?P<desc>[a-z0-9][a-z0-9-]*)";
type Token = (&'static str, Option<(&'static str, &'static str)>);
const TOKENS: [Token; 5] = [
("{type}", Some(("type", TYPE_GROUP))),
("{issue}", Some(("issue", ISSUE_GROUP))),
("{desc}", Some(("desc", DESC_GROUP))),
("{repo}", None),
("{home}", None),
];
const SEGMENTS: [&str; 3] = ["type", "issue", "desc"];
pub fn editable_segments(patterns: &[&str]) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for pattern in patterns {
let mut hits: Vec<(usize, &'static str)> = SEGMENTS
.into_iter()
.filter_map(|segment| pattern.find(&format!("{{{}}}", segment)).map(|at| (at, segment)))
.collect();
hits.sort_by_key(|(at, _)| *at);
for (_, segment) in hits {
if !out.contains(&segment) {
out.push(segment);
}
}
}
out
}
fn segment_marker(segment: &str) -> char {
match segment {
"type" => '\u{1}',
"issue" => '\u{2}',
_ => '\u{3}',
}
}
const OPAQUE_MARKER: char = '\u{4}';
pub const BRANCH_TYPES: &[(&str, &str)] = &[
("feat", "New feature implementation"),
("fix", "Bug fix"),
("hotfix", "Critical production bug fix"),
("docs", "Documentation changes"),
("test", "Test additions or modifications"),
("refactor", "Code restructuring"),
("chore", "Maintenance tasks"),
("perf", "Performance improvements"),
("ci", "CI/CD configuration"),
("build", "Build system changes"),
];
pub fn default_branch_types() -> Vec<BranchType> {
BRANCH_TYPES
.iter()
.map(|(name, desc)| BranchType {
name: (*name).into(),
description: (*desc).into(),
})
.collect()
}
#[derive(Debug, Clone)]
pub struct BranchSpec {
pub type_: String,
pub issue: String,
pub desc: String,
}
impl BranchSpec {
pub fn new(type_: impl Into<String>, issue: impl Into<String>, desc: impl Into<String>) -> Result<Self> {
Self::new_with_types(type_, issue, desc, &default_branch_types())
}
pub fn new_with_types(
type_: impl Into<String>,
issue: impl Into<String>,
desc: impl Into<String>,
allowed: &[BranchType],
) -> Result<Self> {
let s = Self {
type_: type_.into(),
issue: issue.into(),
desc: kebab(&desc.into()),
};
s.validate_against(allowed)?;
Ok(s)
}
pub fn new_with_required(
type_: impl Into<String>,
issue: impl Into<String>,
desc: impl Into<String>,
allowed: &[BranchType],
required: &[&str],
) -> Result<Self> {
let s = Self {
type_: type_.into(),
issue: issue.into(),
desc: kebab(&desc.into()),
};
s.validate_with_required(allowed, required)?;
Ok(s)
}
pub fn validate(&self) -> Result<()> {
self.validate_against(&default_branch_types())
}
pub fn validate_against(&self, allowed: &[BranchType]) -> Result<()> {
self.validate_with_required(allowed, &SEGMENTS)
}
pub fn validate_with_required(&self, allowed: &[BranchType], required: &[&str]) -> Result<()> {
if required.contains(&"type") && !allowed.iter().any(|t| t.name == self.type_) {
let names = allowed.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
return Err(GwmError::InvalidBranchType {
got: self.type_.clone(),
allowed: names,
});
}
if required.contains(&"issue") && !ISSUE_RE.is_match(&self.issue) {
return Err(GwmError::InvalidIssue(self.issue.clone()));
}
if required.contains(&"desc") && !DESC_RE.is_match(&self.desc) {
return Err(GwmError::InvalidDescription(self.desc.clone()));
}
Ok(())
}
pub fn branch_name(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
expand_placeholders(
&cfg.branch_pattern,
repo,
Some(&self.type_),
Some(&self.issue),
Some(&self.desc),
None,
)
}
pub fn worktree_dirname(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
expand_placeholders(
&cfg.path_pattern,
repo,
Some(&self.type_),
Some(&self.issue),
Some(&self.desc),
None,
)
}
pub fn worktree_path(
&self,
cfg: &WorktreeConfig,
repo: &str,
repo_path: &std::path::Path,
) -> Result<std::path::PathBuf> {
let base = expand_placeholders(
&cfg.base,
repo,
Some(&self.type_),
Some(&self.issue),
Some(&self.desc),
Some(repo_path),
)?;
let dir = self.worktree_dirname(cfg, repo)?;
Ok(std::path::PathBuf::from(base).join(dir))
}
}
pub const MAX_DIR_COMPONENT_BYTES: usize = 255;
const GIT_REF_LOCK_SUFFIX_BYTES: usize = ".lock".len();
const STRUCTURED_BASE_PLACEHOLDERS: [&str; 3] = ["{type}", "{issue}", "{desc}"];
const WINDOWS_FORBIDDEN_CHARS: [char; 4] = ['<', '>', '"', '|'];
const WINDOWS_RESERVED_STEMS: [&str; 28] = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
];
fn is_windows_reserved_segment(segment: &str) -> bool {
let stem = segment.split('.').next().unwrap_or(segment);
WINDOWS_RESERVED_STEMS
.iter()
.any(|reserved| stem.eq_ignore_ascii_case(reserved))
}
#[derive(Debug, Clone)]
pub enum WorktreeName {
Structured(BranchSpec),
Freeform(String),
}
impl WorktreeName {
pub fn freeform(input: &str) -> Result<Self> {
let name = input;
let reject = |reason: &str| {
Err(GwmError::InvalidWorktreeName {
name: input.to_string(),
reason: reason.to_string(),
})
};
if name.is_empty() {
return reject("empty");
}
if name.split('/').any(|part| part == "." || part == "..") {
return reject("`.` and `..` are not usable as a directory name");
}
if name.contains('\0') {
return reject("contains a NUL byte");
}
if name.starts_with('-') {
return reject("a leading `-` makes the name unusable as a command argument");
}
if name.contains('{') || name.contains('}') {
return reject("`{` and `}` would be re-substituted when a lifecycle hook expands its placeholders");
}
if name.len() > MAX_DIR_COMPONENT_BYTES {
return reject(&format!(
"{} bytes long — a worktree directory is a single path component, capped at {}",
name.len(),
MAX_DIR_COMPONENT_BYTES
));
}
let last = name.rsplit('/').next().unwrap_or(name);
if last.len() + GIT_REF_LOCK_SUFFIX_BYTES > MAX_DIR_COMPONENT_BYTES {
return reject(&format!(
"its last segment is {} bytes — git writes `refs/heads/<name>.lock` first, leaving {} for it",
last.len(),
MAX_DIR_COMPONENT_BYTES - GIT_REF_LOCK_SUFFIX_BYTES
));
}
if !git2::Branch::name_is_valid(name).unwrap_or(false) {
return reject(
"not a valid git branch name — git rejects spaces, `~ ^ : ? * [ \\`, `@{`, `..`, \
leading/trailing `/`, a trailing `.`, a `.lock` suffix and `HEAD`",
);
}
if let Some(ch) = name.chars().find(|c| WINDOWS_FORBIDDEN_CHARS.contains(c)) {
return reject(&format!(
"`{}` cannot appear in a directory name on Windows — the branch would be \
unusable on a teammate's machine even though git accepts it",
ch
));
}
for segment in name.split('/') {
if segment.ends_with('.') {
return reject(&format!(
"`{}` ends with `.`, which Windows refuses as a directory name — git only \
applies that rule to the last segment of a branch",
segment
));
}
if is_windows_reserved_segment(segment) {
return reject(&format!(
"`{}` is a reserved device name on Windows (`CON`, `PRN`, `AUX`, `NUL`, \
`COM1`-`COM9`, `LPT1`-`LPT9`, with or without an extension) — no path \
component may be one",
segment
));
}
}
Ok(Self::Freeform(name.to_string()))
}
pub fn branch_name(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
match self {
Self::Structured(spec) => spec.branch_name(cfg, repo),
Self::Freeform(name) => Ok(name.clone()),
}
}
pub fn worktree_dirname(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
match self {
Self::Structured(spec) => spec.worktree_dirname(cfg, repo),
Self::Freeform(name) => Ok(name.replace('/', "-")),
}
}
pub fn worktree_path(
&self,
cfg: &WorktreeConfig,
repo: &str,
repo_path: &std::path::Path,
) -> Result<std::path::PathBuf> {
match self {
Self::Structured(spec) => spec.worktree_path(cfg, repo, repo_path),
Self::Freeform(_) => {
if let Some(ph) = STRUCTURED_BASE_PLACEHOLDERS.iter().find(|ph| cfg.base.contains(**ph)) {
return Err(GwmError::Config(format!(
"worktree.base `{}` uses `{}`, which a free-form name has no value for \
(it would be left literal in the path) — drop it from base, or create \
the worktree with <type> <issue> <desc>",
cfg.base, ph
)));
}
let base = expand_placeholders(&cfg.base, repo, None, None, None, Some(repo_path))?;
Ok(std::path::PathBuf::from(base).join(self.worktree_dirname(cfg, repo)?))
}
}
}
}
#[derive(Debug, Clone)]
pub struct BranchParser {
re: Regex,
constants: Vec<(&'static str, String)>,
}
impl BranchParser {
pub fn compile(pattern: &str, repo: &str, types: &[BranchType]) -> Result<Self> {
let mut re = String::from("^");
let mut seen: Vec<&str> = Vec::new();
let mut authored = String::new();
let mut pending: Option<(&'static str, String)> = None;
let mut rest = pattern;
while !rest.is_empty() {
let Some((at, token, group)) = TOKENS
.iter()
.filter_map(|(token, group)| rest.find(token).map(|at| (at, *token, *group)))
.min_by_key(|(at, ..)| *at)
else {
authored.push_str(rest);
push_literal(&mut re, rest, &mut pending);
break;
};
if at > 0 {
authored.push_str(&rest[..at]);
push_literal(&mut re, &rest[..at], &mut pending);
}
rest = &rest[at + token.len()..];
match group {
Some((name, group)) => {
if seen.contains(&name) {
return Err(GwmError::Config(format!(
"worktree.branch_pattern `{}` uses `{{{}}}` more than once; every occurrence expands \
to the same value, which cannot be read back",
sanitise_for_terminal(pattern),
name
)));
}
if let Some((left, sep)) = pending.as_ref() {
if boundary_can_shift(left, sep, name) {
return Err(GwmError::Config(if sep.is_empty() {
format!(
"worktree.branch_pattern `{}` puts `{{{}}}` straight after `{{{}}}` with nothing \
between them and both can hold the same characters, so a branch it writes cannot \
be read back unambiguously — separate them with a literal (`-`, `_`, `/`, …)",
sanitise_for_terminal(pattern),
name,
left
)
} else {
format!(
"worktree.branch_pattern `{}` separates `{{{}}}` from `{{{}}}` with `{}`, which \
could be read as part of either, so a branch it writes splits at the wrong place \
— separate them with a character neither can contain (`/`, `_`, `#`, `.`, …)",
sanitise_for_terminal(pattern),
left,
name,
sanitise_for_terminal(sep)
)
}));
}
}
seen.push(name);
re.push_str(group);
authored.push(segment_marker(name));
pending = Some((name, String::new()));
}
None => {
let text = if token == "{home}" {
dirs::home_dir()
.ok_or_else(|| GwmError::Config("cannot resolve $HOME".into()))?
.to_string_lossy()
.to_string()
} else {
repo.to_string()
};
authored.push(OPAQUE_MARKER);
push_literal(&mut re, &text, &mut pending);
}
}
}
re.push('$');
let re = Regex::new(&re).map_err(|e| {
GwmError::Config(format!(
"worktree.branch_pattern `{}` does not compile into a parser: {}",
sanitise_for_terminal(pattern),
e
))
})?;
let constants = literal_constants(&authored, &seen, types);
let parser = Self { re, constants };
parser.mirrors_formatter(pattern, repo)?;
Ok(parser)
}
fn mirrors_formatter(&self, pattern: &str, repo: &str) -> Result<()> {
if pattern.starts_with('~') {
return Ok(());
}
const PROBE: (&str, &str, &str) = ("feat", "42", "probe");
let Ok(written) = expand_placeholders(pattern, repo, Some(PROBE.0), Some(PROBE.1), Some(PROBE.2), None) else {
return Ok(());
};
let agrees = self.parse(&written).is_some_and(|spec| {
SEGMENTS.iter().all(|segment| {
!self.re.capture_names().flatten().any(|name| name == *segment)
|| match *segment {
"type" => spec.type_ == PROBE.0,
"issue" => spec.issue == PROBE.1,
_ => spec.desc == PROBE.2,
}
})
});
if agrees {
return Ok(());
}
Err(GwmError::Config(format!(
"worktree.branch_pattern `{}` writes `{}`, which the parser derived from it does not read \
back, so gwm would recognise none of the branches this pattern creates",
sanitise_for_terminal(pattern),
sanitise_for_terminal(&written)
)))
}
pub fn from_config(config: &crate::config::Config, repo: &str) -> Self {
let types = config.resolved_branch_types().types;
Self::compile(&config.worktree.branch_pattern, repo, &types).unwrap_or_else(|_| Self::inert())
}
pub fn for_repo(repo: &git2::Repository) -> Self {
let config = repo
.workdir()
.and_then(|wd| crate::config::Config::load_for_repo(wd).ok())
.unwrap_or_default();
Self::from_config(&config, &crate::worktree::repo_name(repo))
}
pub fn builtin() -> &'static Self {
static BUILTIN: LazyLock<BranchParser> = LazyLock::new(|| {
BranchParser::compile(&crate::config::default_branch_pattern(), "", &default_branch_types())
.expect("the default branch_pattern compiles")
});
&BUILTIN
}
pub fn reads_segment(&self, segment: &str) -> bool {
self.captures_segment(segment) || self.constants.iter().any(|(name, _)| *name == segment)
}
pub fn captures_segment(&self, segment: &str) -> bool {
self.re.capture_names().flatten().any(|name| name == segment)
}
pub fn constants(&self) -> &[(&'static str, String)] {
&self.constants
}
fn inert() -> Self {
Self {
re: Regex::new(r"\z\A").expect("static inert regex compiles"),
constants: Vec::new(),
}
}
pub fn parse(&self, branch: &str) -> Option<BranchSpec> {
let cap = self.re.captures(branch)?;
let seg = |name: &str| {
cap
.name(name)
.map(|m| m.as_str().to_string())
.or_else(|| {
self
.constants
.iter()
.find(|(seg, _)| *seg == name)
.map(|(_, value)| value.clone())
})
.unwrap_or_default()
};
Some(BranchSpec {
type_: seg("type"),
issue: seg("issue"),
desc: seg("desc"),
})
}
}
pub fn worktree_spec(
config: &crate::config::Config,
repo: &str,
branch: &str,
dirname: Option<&str>,
) -> Option<BranchSpec> {
let branch_parser = BranchParser::from_config(config, repo);
let mut spec = branch_parser.parse(branch)?;
let types = config.resolved_branch_types().types;
let from_path = dirname
.filter(|name| !name.is_empty())
.zip(BranchParser::compile(&config.worktree.path_pattern, repo, &types).ok())
.and_then(|(name, parser)| parser.parse(name).map(|read| (parser, read)));
let Some((path_parser, read)) = from_path else {
return Some(spec);
};
for segment in SEGMENTS {
if branch_parser.captures_segment(segment) || !path_parser.captures_segment(segment) {
continue;
}
let value = match segment {
"type" => &read.type_,
"issue" => &read.issue,
_ => &read.desc,
};
if value.is_empty() {
continue;
}
match segment {
"type" => spec.type_ = value.clone(),
"issue" => spec.issue = value.clone(),
_ => spec.desc = value.clone(),
}
}
Some(spec)
}
fn push_literal(re: &mut String, text: &str, pending: &mut Option<(&'static str, String)>) {
if text.is_empty() {
return;
}
if let Some((_, sep)) = pending.as_mut() {
sep.push_str(text);
}
re.push_str(®ex::escape(text));
}
fn boundary_can_shift(left: &str, sep: &str, right: &str) -> bool {
let sep: Vec<char> = sep.chars().collect();
if sep.is_empty() {
return WITNESSES
.iter()
.any(|&c| segment_accepts(left, c) && segment_starts(right, c));
}
(1..=sep.len()).any(|d| {
sep[..d].iter().all(|&c| segment_accepts(left, c))
&& sep[..sep.len() - d] == sep[d..]
&& segment_can_start_with(right, &sep[sep.len() - d..])
})
}
const WITNESSES: [char; 3] = ['a', '0', '-'];
fn segment_starts(segment: &str, c: char) -> bool {
match segment {
"desc" => c.is_ascii_lowercase() || c.is_ascii_digit(),
_ => segment_accepts(segment, c),
}
}
fn segment_can_start_with(segment: &str, prefix: &[char]) -> bool {
match prefix.split_first() {
None => true,
Some((first, rest)) => segment_starts(segment, *first) && rest.iter().all(|&c| segment_accepts(segment, c)),
}
}
fn segment_accepts(segment: &str, c: char) -> bool {
match segment {
"type" => c.is_ascii_lowercase(),
"issue" => c.is_ascii_digit(),
_ => c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-',
}
}
fn literal_constants(authored: &str, captured: &[&str], types: &[BranchType]) -> Vec<(&'static str, String)> {
let missing: Vec<(usize, &'static str)> = SEGMENTS
.iter()
.enumerate()
.filter(|(_, segment)| !captured.contains(segment))
.map(|(rank, segment)| (rank, *segment))
.collect();
let all = assignments(authored, &missing, types, 0);
let best = all.iter().map(Vec::len).max().unwrap_or(0);
let top: Vec<Vec<(&'static str, String)>> = all.into_iter().filter(|a| a.len() == best).collect();
let Some((first, rest)) = top.split_first() else {
return Vec::new();
};
first
.iter()
.filter(|(segment, value)| {
rest
.iter()
.all(|reading| reading.iter().any(|(s, v)| s == segment && v == value))
})
.cloned()
.collect()
}
fn assignments(
authored: &str,
missing: &[(usize, &'static str)],
types: &[BranchType],
cursor: usize,
) -> Vec<Vec<(&'static str, String)>> {
let Some(((rank, segment), rest)) = missing.split_first().map(|(head, tail)| (*head, tail)) else {
return vec![Vec::new()];
};
let mut out = assignments(authored, rest, types, cursor);
let (lower, upper) = segment_region(authored, rank);
let lower = lower.max(cursor);
if lower < upper {
for (end, value) in charset_runs(&authored[lower..upper], segment) {
let plausible = match segment {
"type" => types.iter().any(|t| t.name == value),
"issue" => ISSUE_RE.is_match(&value),
_ => DESC_RE.is_match(&value),
};
if !plausible {
continue;
}
for tail in assignments(authored, rest, types, lower + end) {
let mut whole = vec![(segment, value.clone())];
whole.extend(tail);
out.push(whole);
}
}
}
out
}
fn segment_region(authored: &str, rank: usize) -> (usize, usize) {
let lower = SEGMENTS[..rank]
.iter()
.filter_map(|earlier| {
let marker = segment_marker(earlier);
authored.find(marker).map(|at| at + marker.len_utf8())
})
.max()
.unwrap_or(0);
let upper = SEGMENTS[rank + 1..]
.iter()
.filter_map(|later| authored.find(segment_marker(later)))
.min()
.unwrap_or(authored.len());
(lower, upper)
}
fn charset_runs(text: &str, segment: &str) -> Vec<(usize, String)> {
let mut out: Vec<(usize, String)> = Vec::new();
let mut run: Option<usize> = None;
for (at, c) in text.char_indices().chain(std::iter::once((text.len(), '\0'))) {
if at < text.len() && segment_accepts(segment, c) {
run.get_or_insert(at);
continue;
}
if let Some(start) = run.take() {
let value = text[start..at].trim_start_matches('-');
if !value.is_empty() {
out.push((at, value.to_string()));
}
}
}
out
}
fn segment_consumers(segment: &str) -> &'static str {
match segment {
"type" => "gitmoji / `gwm commit-prefix`, `[pr_template.by_type]` selection, remove/bootstrap hook placeholders and the TUI rename",
"issue" => "issue auto-linking from the branch name, `gwm pr` body placeholders, remove/bootstrap hook placeholders and the TUI rename",
_ => "`gwm pr` body placeholders, remove/bootstrap hook placeholders and the TUI rename",
}
}
fn segment_absent_verb(segment: &str) -> &'static str {
match segment {
"type" => "have no branch type to work from",
"issue" => "have no issue number to work from",
_ => "have no description to work from",
}
}
pub fn branch_pattern_warning(pattern: &str, repo: &str, types: &[BranchType]) -> Option<String> {
const ISSUES: [&str; 2] = ["7", "42"];
const DESCS: [&str; 4] = ["probe", "probe-desc", "123", "123-probe"];
let parser = match BranchParser::compile(pattern, repo, types) {
Ok(p) => p,
Err(e) => return Some(format!("{}", e)),
};
let missing: Vec<&str> = SEGMENTS.into_iter().filter(|seg| !parser.reads_segment(seg)).collect();
let (mut unparseable, mut parsed, mut lossy) = (None::<String>, 0usize, 0usize);
let (mut bad_type, mut bad_issue, mut bad_desc) = (false, false, false);
let mut probes = 0usize;
let usable = types
.iter()
.map(|t| t.name.as_str())
.filter(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_lowercase()));
for type_ in usable {
for issue in ISSUES {
for desc in DESCS {
let formatted = expand_placeholders(pattern, repo, Some(type_), Some(issue), Some(desc), None).ok()?;
probes += 1;
match parser.parse(&formatted) {
None => {
unparseable.get_or_insert(formatted);
}
Some(back) => {
parsed += 1;
let (t, i, d) = (
!missing.contains(&"type") && back.type_ != type_,
!missing.contains(&"issue") && back.issue != issue,
!missing.contains(&"desc") && back.desc != desc,
);
lossy += usize::from(t || i || d);
bad_type |= t;
bad_issue |= i;
bad_desc |= d;
}
}
}
}
}
if probes == 0 {
return None;
}
let unparsed = probes - parsed;
if missing.is_empty() && unparsed == 0 && lossy == 0 {
return None;
}
let mut parts: Vec<String> = Vec::new();
if !missing.is_empty() {
let tokens = missing.iter().map(|s| format!("`{{{}}}`", s)).collect::<Vec<_>>();
let losses = missing
.iter()
.map(|seg| format!("{} {}", segment_consumers(seg), segment_absent_verb(seg)))
.collect::<Vec<_>>();
parts.push(format!(
"it carries no {}, so {} — write {} into the pattern to get them back",
tokens.join(" and "),
losses.join("; "),
tokens.join(" and ")
));
}
if let Some(example) = unparseable {
parts.push(format!(
"{} of the {} branch shapes probed match nothing at all (e.g. `{}`), so issue auto-linking from the branch name, gitmoji / `gwm commit-prefix`, `gwm pr` template selection and placeholders, remove/bootstrap hook placeholders, the TUI rename and the branch-convention check are inactive on those (PR/MR detection is unaffected — it queries the forge with the full branch name)",
unparsed,
probes,
sanitise_for_terminal(&example)
));
}
if lossy > 0 {
let mut broken: Vec<String> = Vec::new();
for (flag, seg, verb) in [
(bad_type, "type", "read the wrong branch type"),
(bad_issue, "issue", "target the wrong issue"),
(bad_desc, "desc", "see the wrong description"),
] {
if flag {
broken.push(format!("`{}`, so {} {}", seg, segment_consumers(seg), verb));
}
}
parts.push(format!(
"{} of the {} branch shapes probed parse but read back {}",
lossy,
probes,
broken.join("; ")
));
}
Some(format!(
"worktree.branch_pattern `{}` does not round-trip: {}",
sanitise_for_terminal(pattern),
parts.join("; and ")
))
}
pub fn sanitise_for_terminal(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_control() || is_display_reordering(c) {
'?'
} else {
c
}
})
.collect()
}
pub(crate) fn is_display_reordering(c: char) -> bool {
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
pub fn sanitise_diagnostic_for_terminal(s: &str) -> String {
let cleaned = sanitise_block_for_terminal(s);
let mut out = String::with_capacity(cleaned.len());
for (i, line) in cleaned.split('\n').enumerate() {
if i > 0 {
out.push_str("\n ");
}
out.push_str(line);
}
out
}
pub fn sanitise_block_for_terminal(s: &str) -> String {
let normalised = s.replace("\r\n", "\n");
normalised
.chars()
.map(|c| {
if (c.is_control() && c != '\n' && c != '\t') || is_display_reordering(c) {
'?'
} else {
c
}
})
.collect()
}
pub fn kebab(input: &str) -> String {
let lower = input.to_lowercase();
let mut out = String::with_capacity(lower.len());
let mut prev_dash = false;
for c in lower.chars() {
if c.is_ascii_alphanumeric() {
out.push(c);
prev_dash = false;
} else if !prev_dash && !out.is_empty() {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_string()
}