gwm/naming.rs
1use crate::config::{expand_placeholders, BranchType, WorktreeConfig};
2use crate::error::{GwmError, Result};
3use regex::Regex;
4use std::sync::LazyLock;
5
6/// Compile-time literal regexes lifted to module statics so each branch
7/// validation / parse runs at ~50ns instead of recompiling the pattern
8/// per call (issue #97). `LazyLock::new` defers the work until the
9/// first access; `expect` is acceptable here because the input is a
10/// hard-coded literal — a regex-compile failure would be a developer
11/// bug caught by the test suite at first use, not a user-facing error
12/// path the CLAUDE.md "no unwrap on user paths" rule targets.
13///
14/// `ISSUE_RE` pins the digits-only contract on issue numbers (no
15/// scientific notation, no hex, no leading zeros stripped). `DESC_RE`
16/// matches the post-`kebab` shape — leading alphanumeric, then a tail
17/// of alphanumeric / dash.
18///
19/// There is deliberately no `BRANCH_RE` here any more (issue #417): the
20/// parser is compiled from `worktree.branch_pattern` by [`BranchParser`],
21/// so the shape gwm reads is the shape gwm writes, by construction.
22static ISSUE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\d+$").expect("static ISSUE_RE compiles"));
23static DESC_RE: LazyLock<Regex> =
24 LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]*$").expect("static DESC_RE compiles"));
25
26/// Charset each capturing token contributes to the compiled regex.
27///
28/// These mirror the validators the *formatter* side enforces, so the parser
29/// accepts exactly the strings [`BranchSpec::branch_name`] can emit and
30/// nothing more: `{issue}` is `ISSUE_RE`, `{desc}` is `DESC_RE`, and `{type}`
31/// is the `^[a-z]+$` that `validate_branch_types` pins on every configured
32/// name.
33///
34/// `{type}` is deliberately *not* an alternation of the repo's configured
35/// types, which issue #417 proposed. It would narrow the parser to branches
36/// the repo can create *today*, so a branch created before a type was retired
37/// from `.gwm.toml` would stop being recognised as gwm's — a regression on a
38/// name the previous release read fine. Nothing needs the narrowing either:
39/// once adjacent placeholders are refused, `[a-z]+` splits every pattern the
40/// alternation splits (measured across the whole documented pattern table),
41/// and the one consumer that genuinely requires a *configured* type — the TUI
42/// rename — checks the resolved list itself and says so precisely.
43const TYPE_GROUP: &str = r"(?P<type>[a-z]+)";
44const ISSUE_GROUP: &str = r"(?P<issue>\d+)";
45const DESC_GROUP: &str = r"(?P<desc>[a-z0-9][a-z0-9-]*)";
46
47/// Every token [`crate::config::expand_placeholders`] substitutes on the branch
48/// path, paired with the capture group it compiles to — `None` for the two the
49/// formatter resolves to fixed text before a branch name exists.
50///
51/// `{repo_path}` / `{repo_parent}` are absent on purpose: `BranchSpec::branch_name`
52/// passes no `repo_path`, so the formatter leaves them verbatim and so does the
53/// compiler. `tests/naming_tests.rs` reads the token list back out of
54/// `expand_placeholders` and fails if the two drift apart.
55type Token = (&'static str, Option<(&'static str, &'static str)>);
56const TOKENS: [Token; 5] = [
57 ("{type}", Some(("type", TYPE_GROUP))),
58 ("{issue}", Some(("issue", ISSUE_GROUP))),
59 ("{desc}", Some(("desc", DESC_GROUP))),
60 ("{repo}", None),
61 ("{home}", None),
62];
63
64/// The three segments a branch name carries, in the order constants are
65/// resolved: strictest oracle first, so a token that could serve two segments
66/// goes to the one that can be sure about it.
67const SEGMENTS: [&str; 3] = ["type", "issue", "desc"];
68
69/// Which of the three editable segments the given patterns ask the user to
70/// supply, in the order the patterns write them (issue #418).
71///
72/// These are the tokens [`crate::config::expand_placeholders`] fills from a
73/// value the user typed, as opposed to the ones gwm resolves from the repo and
74/// the environment (`{repo}`, `{home}`, `{repo_path}`, `{repo_parent}`). So this
75/// is exactly the field set the TUI create form has to present: a pattern that
76/// writes no issue number must not ask for one, and asking anyway is not merely
77/// noise — [`BranchSpec::validate_against`] then refuses to submit until the
78/// field is filled with a number the patterns discard.
79///
80/// **Order is the pattern's, not the canonical triple's.** `{desc}-{issue}` is a
81/// legitimate convention, and a form whose Tab order disagreed with the name
82/// being written would read backwards.
83///
84/// Pass every pattern the triple feeds. `base` is one of them:
85/// [`BranchSpec::worktree_path`] expands `{type}` / `{issue}` / `{desc}` in it,
86/// so a segment only `base` carries still names a real directory on disk and
87/// still has to be collected.
88///
89/// De-duplicated on first occurrence, which cannot be inherited from
90/// [`BranchParser::compile`]'s stricter contract: the compiler refuses a
91/// repeated capturing token, but `expand_placeholders` is a chain of
92/// `str::replace` and substitutes every occurrence quite happily.
93///
94/// Reads the pattern as written, which is only correct because
95/// `expand_placeholders` is single-pass (#494): an expansion is a value, so a
96/// repo named `api-{type}` contributes the text `api-{type}` and not a `{type}`
97/// placeholder. While the formatter re-substituted its own output, #418 had to
98/// resolve `{home}` / `{repo}` here first to stay in step with it; removing the
99/// root removed the need, and leaving that resolution in would now invent a
100/// field for a token the formatter writes literally.
101pub fn editable_segments(patterns: &[&str]) -> Vec<&'static str> {
102 let mut out: Vec<&'static str> = Vec::new();
103 for pattern in patterns {
104 let mut hits: Vec<(usize, &'static str)> = SEGMENTS
105 .into_iter()
106 .filter_map(|segment| pattern.find(&format!("{{{}}}", segment)).map(|at| (at, segment)))
107 .collect();
108 hits.sort_by_key(|(at, _)| *at);
109 for (_, segment) in hits {
110 if !out.contains(&segment) {
111 out.push(segment);
112 }
113 }
114 }
115 out
116}
117
118/// Where a placeholder stood, in the literal text [`literal_constants`] reads.
119///
120/// Two jobs. It **separates**: the literals in `1{type}2-{desc}` are not one
121/// token, and fusing them made the recovery freeze the issue number `12`, which
122/// no branch the pattern writes contains. And it **positions**: a literal
123/// before `{issue}` can only be the type, one after it only the description,
124/// which is how `feat/#{issue}-fix` recovers both even though `feat` and `fix`
125/// are each a configured branch type.
126///
127/// Every marker is outside all three charsets, so no candidate spans one.
128fn segment_marker(segment: &str) -> char {
129 match segment {
130 "type" => '\u{1}',
131 "issue" => '\u{2}',
132 _ => '\u{3}',
133 }
134}
135
136/// Where `{repo}` / `{home}` stood. Their expansion is real text in the branch
137/// name but nobody authored it, so it separates the literals around it without
138/// positioning anything.
139const OPAQUE_MARKER: char = '\u{4}';
140
141/// Built-in branch types — the fallback when `.gwm.toml` carries no
142/// `[[branch_types]]` block. Kept as a `&[(&str, &str)]` const so the
143/// static string table stays compile-time and zero-alloc; the runtime
144/// view is materialised on demand via [`default_branch_types`].
145pub const BRANCH_TYPES: &[(&str, &str)] = &[
146 ("feat", "New feature implementation"),
147 ("fix", "Bug fix"),
148 ("hotfix", "Critical production bug fix"),
149 ("docs", "Documentation changes"),
150 ("test", "Test additions or modifications"),
151 ("refactor", "Code restructuring"),
152 ("chore", "Maintenance tasks"),
153 ("perf", "Performance improvements"),
154 ("ci", "CI/CD configuration"),
155 ("build", "Build system changes"),
156];
157
158/// Runtime view of [`BRANCH_TYPES`] as a `Vec<BranchType>`. Used by
159/// [`crate::config::Config::resolved_branch_types`] when no override
160/// is configured, and by [`BranchSpec::validate`] / [`BranchSpec::new`]
161/// to keep the legacy "no config = built-in defaults" contract.
162pub fn default_branch_types() -> Vec<BranchType> {
163 BRANCH_TYPES
164 .iter()
165 .map(|(name, desc)| BranchType {
166 name: (*name).into(),
167 description: (*desc).into(),
168 })
169 .collect()
170}
171
172#[derive(Debug, Clone)]
173pub struct BranchSpec {
174 pub type_: String,
175 pub issue: String,
176 pub desc: String,
177}
178
179impl BranchSpec {
180 /// Construct a [`BranchSpec`] validated against the built-in branch
181 /// types. Kept for callers (tests, internal helpers) that don't have
182 /// a [`crate::config::Config`] in scope; production code paths
183 /// (`gwm create`, TUI create) should use [`Self::new_with_types`]
184 /// with the resolved list so per-repo overrides are honoured.
185 pub fn new(type_: impl Into<String>, issue: impl Into<String>, desc: impl Into<String>) -> Result<Self> {
186 Self::new_with_types(type_, issue, desc, &default_branch_types())
187 }
188
189 /// Construct a [`BranchSpec`] validated against the supplied list of
190 /// allowed branch types — typically the output of
191 /// [`crate::config::Config::resolved_branch_types`].
192 pub fn new_with_types(
193 type_: impl Into<String>,
194 issue: impl Into<String>,
195 desc: impl Into<String>,
196 allowed: &[BranchType],
197 ) -> Result<Self> {
198 let s = Self {
199 type_: type_.into(),
200 issue: issue.into(),
201 desc: kebab(&desc.into()),
202 };
203 s.validate_against(allowed)?;
204 Ok(s)
205 }
206
207 /// As [`Self::new_with_types`], but validating only the segments in
208 /// `required` (issue #418).
209 ///
210 /// `required` is [`editable_segments`] over the repo's patterns: a segment no
211 /// pattern carries is discarded by [`crate::config::expand_placeholders`],
212 /// so demanding a value for it makes the value mandatory *and* thrown away.
213 /// That is what left the TUI create form unusable on a `{type}/{desc}` repo.
214 pub fn new_with_required(
215 type_: impl Into<String>,
216 issue: impl Into<String>,
217 desc: impl Into<String>,
218 allowed: &[BranchType],
219 required: &[&str],
220 ) -> Result<Self> {
221 let s = Self {
222 type_: type_.into(),
223 issue: issue.into(),
224 desc: kebab(&desc.into()),
225 };
226 s.validate_with_required(allowed, required)?;
227 Ok(s)
228 }
229
230 /// Validate against the built-in branch types. Convenience wrapper
231 /// around [`Self::validate_against`] for legacy call sites.
232 pub fn validate(&self) -> Result<()> {
233 self.validate_against(&default_branch_types())
234 }
235
236 /// Validate against the supplied list of allowed branch types. The
237 /// error message produced when the type is rejected enumerates the
238 /// allowed names so the TUI status bar / CLI stderr always shows the
239 /// repo-local truth (built-in or `.gwm.toml`-driven).
240 pub fn validate_against(&self, allowed: &[BranchType]) -> Result<()> {
241 self.validate_with_required(allowed, &SEGMENTS)
242 }
243
244 /// As [`Self::validate_against`], but checking only the segments in
245 /// `required` (issue #418). A segment the repo's patterns do not carry is
246 /// never written anywhere, so there is nothing to validate about it — and
247 /// refusing an empty one would refuse a form the user has filled completely.
248 ///
249 /// Additive: `validate_against` passes all three, so every existing caller
250 /// (the whole CLI surface included) keeps the same contract.
251 pub fn validate_with_required(&self, allowed: &[BranchType], required: &[&str]) -> Result<()> {
252 if required.contains(&"type") && !allowed.iter().any(|t| t.name == self.type_) {
253 let names = allowed.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
254 return Err(GwmError::InvalidBranchType {
255 got: self.type_.clone(),
256 allowed: names,
257 });
258 }
259 if required.contains(&"issue") && !ISSUE_RE.is_match(&self.issue) {
260 return Err(GwmError::InvalidIssue(self.issue.clone()));
261 }
262 if required.contains(&"desc") && !DESC_RE.is_match(&self.desc) {
263 return Err(GwmError::InvalidDescription(self.desc.clone()));
264 }
265 Ok(())
266 }
267
268 pub fn branch_name(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
269 expand_placeholders(
270 &cfg.branch_pattern,
271 repo,
272 Some(&self.type_),
273 Some(&self.issue),
274 Some(&self.desc),
275 None,
276 )
277 }
278
279 pub fn worktree_dirname(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
280 expand_placeholders(
281 &cfg.path_pattern,
282 repo,
283 Some(&self.type_),
284 Some(&self.issue),
285 Some(&self.desc),
286 None,
287 )
288 }
289
290 /// Resolve the absolute worktree path for this spec. `repo_path` is the
291 /// main repo's working directory on disk — it feeds the `{repo_path}` /
292 /// `{repo_parent}` placeholders so a `base` like `{repo_parent}/worktrees`
293 /// can be expressed relative to the repo (matching, e.g., an editor's
294 /// `../worktrees` convention).
295 pub fn worktree_path(
296 &self,
297 cfg: &WorktreeConfig,
298 repo: &str,
299 repo_path: &std::path::Path,
300 ) -> Result<std::path::PathBuf> {
301 let base = expand_placeholders(
302 &cfg.base,
303 repo,
304 Some(&self.type_),
305 Some(&self.issue),
306 Some(&self.desc),
307 Some(repo_path),
308 )?;
309 let dir = self.worktree_dirname(cfg, repo)?;
310 Ok(std::path::PathBuf::from(base).join(dir))
311 }
312}
313
314/// How a worktree got its name (issue #416).
315///
316/// [`Self::Structured`] is the canonical `<type>/#<issue>-<desc>` triple that
317/// `branch_pattern` / `path_pattern` expand. [`Self::Freeform`] is a name the
318/// user simply chose — `gwm create --name spike-redis` — and it deliberately
319/// escapes the convention: no branch type, no issue, no `DESC_RE`.
320///
321/// The patterns do not apply to a free-form name, because they are written in
322/// terms of `{type}` / `{issue}` / `{desc}` and it has none of them. `base`
323/// still applies, so a free-form worktree lands beside the structured ones
324/// rather than somewhere else — but only for the placeholders it documents
325/// (`{home}` / `{repo}` / `{repo_path}` / `{repo_parent}`). The structured
326/// path also feeds `{type}` / `{issue}` / `{desc}` through `base`; a `base`
327/// written with one of those is refused here rather than expanded literally.
328/// Max bytes in a single path component. `NAME_MAX` is 255 on every
329/// filesystem gwm targets; git's own ref check is silent about length,
330/// so the worktree directory is the binding constraint.
331///
332/// Public because the TUI create form has to stop typing at exactly this
333/// number: a form that stopped short would silently truncate a name the
334/// validator would have accepted, and submit a different branch than the
335/// one that was typed.
336pub const MAX_DIR_COMPONENT_BYTES: usize = 255;
337
338/// Bytes git tacks onto the ref's **final** component while creating it:
339/// `refs/heads/<name>.lock` has to exist before `refs/heads/<name>` does.
340/// Earlier components are plain directories and carry no suffix.
341const GIT_REF_LOCK_SUFFIX_BYTES: usize = ".lock".len();
342
343/// The `base` placeholders only the structured triple can supply. A
344/// free-form name has no value for any of them, and `expand_placeholders`
345/// leaves an unfed placeholder literal, so a `base` written with one of
346/// these has to be refused rather than turned into a directory called
347/// `{type}`.
348const STRUCTURED_BASE_PLACEHOLDERS: [&str; 3] = ["{type}", "{issue}", "{desc}"];
349
350/// The Win32 path characters `Branch::name_is_valid` does *not* already
351/// refuse. Windows forbids nine in a path component (`< > : " / \ | ? *`);
352/// measured against the oracle, it already rejects `: \ ? *`, and `/` is the
353/// ref separator [`WorktreeName::worktree_dirname`] flattens to `-`. These
354/// four are the residual, and flattening leaves each of them untouched, so
355/// they reach the directory name verbatim.
356const WINDOWS_FORBIDDEN_CHARS: [char; 4] = ['<', '>', '"', '|'];
357
358/// The MS-DOS device names, reserved by Win32 in *every* directory rather
359/// than only at a volume root. Taken verbatim from [Naming Files, Paths, and
360/// Namespaces][win32]: `COM0` and `LPT0` are deliberately absent (they are
361/// not on that list), while the ISO 8859-1 superscripts `¹ ² ³` are on it,
362/// because Windows reads them as digits in a device name.
363///
364/// [win32]: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
365const WINDOWS_RESERVED_STEMS: [&str; 28] = [
366 "CON", "PRN", "AUX", "NUL", //
367 "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "COM¹", "COM²", "COM³", //
368 "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
369];
370
371/// Whether `segment` is a reserved Win32 device name.
372///
373/// The comparison is on the stem, everything before the **first** `.`: Win32
374/// documents `NUL.txt` and `NUL.tar.gz` as both equivalent to `NUL`. Taking
375/// the first rather than testing every dot-separated piece is what keeps
376/// `x.CON` legal, which it is.
377///
378/// Case-insensitive, since Windows path comparison is. `eq_ignore_ascii_case`
379/// is enough even for the superscript entries: `¹ ² ³` have no case variants,
380/// so their bytes compare equal either way.
381fn is_windows_reserved_segment(segment: &str) -> bool {
382 let stem = segment.split('.').next().unwrap_or(segment);
383 WINDOWS_RESERVED_STEMS
384 .iter()
385 .any(|reserved| stem.eq_ignore_ascii_case(reserved))
386}
387
388#[derive(Debug, Clone)]
389pub enum WorktreeName {
390 Structured(BranchSpec),
391 /// Already validated by [`WorktreeName::freeform`] — constructing this
392 /// variant directly bypasses the ref/path checks.
393 Freeform(String),
394}
395
396impl WorktreeName {
397 /// Validate a user-supplied free-form name.
398 ///
399 /// The bar is deliberately low. `Spike_Redis`, `2026.07.27` and
400 /// `réécriture` are all fine — refusing them would defeat the point of the
401 /// flag. The rules are enumerated from the three things the name has to be
402 /// at once, rather than accreted one reviewer example at a time:
403 ///
404 /// 1. **A git branch.** Delegated to libgit2's own
405 /// [`git2::Branch::name_is_valid`] — the branch-level oracle, not the
406 /// reference-level one, because they disagree: `refs/heads/HEAD` is a
407 /// syntactically valid *reference* name while `HEAD` is not a usable
408 /// *branch* name. The authority on what git accepts is git.
409 /// 2. **A single filesystem path component** — the worktree directory. A
410 /// branch name is a *path* of components, so the two have different
411 /// limits: bounded at [`MAX_DIR_COMPONENT_BYTES`], and no `.` / `..`.
412 /// 3. **A literal value in placeholder expansion.** Two of the three
413 /// expanders substitute in one pass and can no longer rewrite a value
414 /// from the inside — `lifecycle::expand` since the hook-injection patch,
415 /// `config::expand_placeholders` since #494. The third,
416 /// `launcher::expand`, still chains `str::replace` over its own output,
417 /// and it substitutes the worktree **path** before `{base}` / `{head}`,
418 /// so a brace reaching that path is re-substituted inside an already
419 /// shell-quoted value. So this rule is not merely defensive: it is what
420 /// keeps a chosen name out of that surface.
421 ///
422 /// Plus one rule that belongs to none of them: no leading `-`, which is a
423 /// CLI-ergonomics rule (git accepts it).
424 ///
425 /// Consumer (2) has a second half, added by #475: the directory has to be
426 /// hostable on **Windows** too, which forbids `< > " |` and the reserved
427 /// device names ([`WINDOWS_FORBIDDEN_CHARS`], [`WINDOWS_RESERVED_STEMS`]).
428 /// The rule is unconditional rather than `#[cfg(windows)]`: a branch
429 /// travels to a teammate's machine through the forge, so a name no Windows
430 /// checkout can host is a cross-platform hazard, and gating it would leave
431 /// a rule only one CI runner ever exercises. The residual list is what
432 /// libgit2 does not already cover, measured against the oracle rather than
433 /// copied wholesale from the Win32 page.
434 ///
435 /// The name is validated exactly as typed. Trimming would accept
436 /// `--name " spike"` and create `spike` instead — a different branch from
437 /// the one that was asked for, which the "the name becomes the branch"
438 /// contract does not allow.
439 pub fn freeform(input: &str) -> Result<Self> {
440 let name = input;
441 let reject = |reason: &str| {
442 Err(GwmError::InvalidWorktreeName {
443 name: input.to_string(),
444 reason: reason.to_string(),
445 })
446 };
447
448 if name.is_empty() {
449 return reject("empty");
450 }
451 // Checked before the ref oracle so the message points at the real
452 // problem: git happily accepts `..` inside a longer component, but a
453 // worktree directory named `..` would escape the base directory.
454 if name.split('/').any(|part| part == "." || part == "..") {
455 return reject("`.` and `..` are not usable as a directory name");
456 }
457 if name.contains('\0') {
458 return reject("contains a NUL byte");
459 }
460 // Not a git rule: libgit2 accepts `-x` as a branch name quite happily
461 // (verified — the oracle below lets it through). It is an *ergonomics*
462 // rule. A leading `-` makes the name unusable as an argument to every
463 // command that would take it back: `gwm remove -x`, `git branch -d -x`,
464 // `cd -x` all read it as a flag.
465 if name.starts_with('-') {
466 return reject("a leading `-` makes the name unusable as a command argument");
467 }
468 // Consumer (3). The reason originally written here has expired:
469 // `lifecycle::expand` used to replace `{branch}` first and `{type}` /
470 // `{issue}` / `{desc}` / `{repo}` after, so a hook asking for `{branch}`
471 // on `spike-{issue}` received `spike-`. That expander has been single-pass
472 // since the hook-injection patch and `config::expand_placeholders` is
473 // since #494.
474 //
475 // The rule stays, and not merely as a belt: `launcher::expand` still
476 // chains `str::replace` over its own output, substituting the worktree
477 // path before `{base}` / `{head}`. `kebab` strips braces out of a
478 // structured description, so this check is the only thing keeping them out
479 // of a name that becomes a directory. (`worktree.base` can still put one
480 // there, which is config rather than a chosen name — a separate surface.)
481 //
482 // Worth stating rather than leaving as folklore: a rejection justified by
483 // a cause that has expired is how the next reader removes it for the wrong
484 // reason, and this one nearly was.
485 if name.contains('{') || name.contains('}') {
486 return reject("`{` and `}` would be re-substituted when a lifecycle hook expands its placeholders");
487 }
488 // A ref is a *path* of components, a worktree directory is a single one,
489 // so `a×130/b×130` is a legal ref and an illegal directory name. Without
490 // this check `worktree::add` creates the branch, then fails on the
491 // directory and leaves the branch orphaned. `/` → `-` is 1:1, so the
492 // flattened dirname has exactly the name's byte length.
493 if name.len() > MAX_DIR_COMPONENT_BYTES {
494 return reject(&format!(
495 "{} bytes long — a worktree directory is a single path component, capped at {}",
496 name.len(),
497 MAX_DIR_COMPONENT_BYTES
498 ));
499 }
500 // The ref side of the same limit, five bytes tighter on the final
501 // component only: git creates `refs/heads/<name>.lock` first, and
502 // `Branch::name_is_valid` checks syntax, never length. Measured: a
503 // 250-byte final segment creates, 251 fails — after `pre_create` hooks
504 // have run. Earlier segments are plain directories, so they keep the
505 // full budget; capping them too would refuse names git accepts.
506 let last = name.rsplit('/').next().unwrap_or(name);
507 if last.len() + GIT_REF_LOCK_SUFFIX_BYTES > MAX_DIR_COMPONENT_BYTES {
508 return reject(&format!(
509 "its last segment is {} bytes — git writes `refs/heads/<name>.lock` first, leaving {} for it",
510 last.len(),
511 MAX_DIR_COMPONENT_BYTES - GIT_REF_LOCK_SUFFIX_BYTES
512 ));
513 }
514
515 // Consumer (1). The branch-level oracle, not `Reference::is_valid_name`
516 // on `refs/heads/<name>`: that one accepts `HEAD`, which `git branch`
517 // refuses and which would collide with the HEAD pseudo-ref.
518 if !git2::Branch::name_is_valid(name).unwrap_or(false) {
519 return reject(
520 "not a valid git branch name — git rejects spaces, `~ ^ : ? * [ \\`, `@{`, `..`, \
521 leading/trailing `/`, a trailing `.`, a `.lock` suffix and `HEAD`",
522 );
523 }
524
525 // Consumer (2) again, for the platform this is not compiled on. Runs
526 // after the oracle so that a name failing both rules gets git's message,
527 // which is the more specific one; by construction everything reaching
528 // here is a name git already accepted.
529 if let Some(ch) = name.chars().find(|c| WINDOWS_FORBIDDEN_CHARS.contains(c)) {
530 return reject(&format!(
531 "`{}` cannot appear in a directory name on Windows — the branch would be \
532 unusable on a teammate's machine even though git accepts it",
533 ch
534 ));
535 }
536 // Per ref segment, not just on the flattened directory name: a loose ref
537 // is a file at `.git/refs/heads/<name>`, so every `/`-separated segment
538 // is a path component there too. `spike/CON` flattens to the perfectly
539 // legal directory `spike-CON` and would still be an unwritable ref.
540 for segment in name.split('/') {
541 // Win32: "Do not end a file or directory name with a space or a
542 // period." Git covers the space in every position, but its own
543 // trailing-period rule applies to the *whole* name, so an inner
544 // segment slips through it: `foo./bar` and `a./b./c` are measured
545 // to pass both `Branch::name_is_valid` and `git check-ref-format`.
546 if segment.ends_with('.') {
547 return reject(&format!(
548 "`{}` ends with `.`, which Windows refuses as a directory name — git only \
549 applies that rule to the last segment of a branch",
550 segment
551 ));
552 }
553 if is_windows_reserved_segment(segment) {
554 return reject(&format!(
555 "`{}` is a reserved device name on Windows (`CON`, `PRN`, `AUX`, `NUL`, \
556 `COM1`-`COM9`, `LPT1`-`LPT9`, with or without an extension) — no path \
557 component may be one",
558 segment
559 ));
560 }
561 }
562
563 Ok(Self::Freeform(name.to_string()))
564 }
565
566 /// The branch this worktree gets. Structured names expand
567 /// `branch_pattern`; free-form names are the branch.
568 pub fn branch_name(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
569 match self {
570 Self::Structured(spec) => spec.branch_name(cfg, repo),
571 Self::Freeform(name) => Ok(name.clone()),
572 }
573 }
574
575 /// The worktree directory name. A branch may carry `/`; a directory is a
576 /// single path component, so it flattens to `-` — the same relationship
577 /// the default `branch_pattern` / `path_pattern` pair already has
578 /// (`feat/#42-x` on disk is `feat-42-x`).
579 pub fn worktree_dirname(&self, cfg: &WorktreeConfig, repo: &str) -> Result<String> {
580 match self {
581 Self::Structured(spec) => spec.worktree_dirname(cfg, repo),
582 Self::Freeform(name) => Ok(name.replace('/', "-")),
583 }
584 }
585
586 /// Absolute worktree path: `base` (expanded) joined with the directory
587 /// name. `base` applies in both modes.
588 pub fn worktree_path(
589 &self,
590 cfg: &WorktreeConfig,
591 repo: &str,
592 repo_path: &std::path::Path,
593 ) -> Result<std::path::PathBuf> {
594 match self {
595 Self::Structured(spec) => spec.worktree_path(cfg, repo, repo_path),
596 Self::Freeform(_) => {
597 if let Some(ph) = STRUCTURED_BASE_PLACEHOLDERS.iter().find(|ph| cfg.base.contains(**ph)) {
598 return Err(GwmError::Config(format!(
599 "worktree.base `{}` uses `{}`, which a free-form name has no value for \
600 (it would be left literal in the path) — drop it from base, or create \
601 the worktree with <type> <issue> <desc>",
602 cfg.base, ph
603 )));
604 }
605 let base = expand_placeholders(&cfg.base, repo, None, None, None, Some(repo_path))?;
606 Ok(std::path::PathBuf::from(base).join(self.worktree_dirname(cfg, repo)?))
607 }
608 }
609 }
610}
611
612/// The reader half of `worktree.branch_pattern` (issue #417).
613///
614/// [`BranchSpec::branch_name`] writes a branch by expanding the pattern;
615/// this compiles the *same* pattern into the regex that reads it back. One
616/// source of truth, so a repo that customises `branch_pattern` keeps issue
617/// auto-linking, gitmoji, `gwm pr` placeholders, lifecycle hook placeholders
618/// and the TUI rename instead of losing them silently.
619///
620/// The compiler mirrors the exact [`expand_placeholders`] call the formatter
621/// makes — `(pattern, repo, Some(type), Some(issue), Some(desc), None)`:
622///
623/// - `{type}` / `{issue}` / `{desc}` become named capture groups.
624/// - `{repo}` and `{home}` become escaped literals, because the formatter
625/// substitutes them too and their values are fixed at parse time.
626/// - **Everything else is an escaped literal**, including `{repo_path}` /
627/// `{repo_parent}`. That is not an oversight: the formatter passes `None`
628/// for `repo_path`, so those tokens survive into the branch name verbatim
629/// and the parser has to expect them verbatim to round-trip.
630///
631/// The `shellexpand::tilde` pass the formatter ends with is *not* mirrored, so
632/// a pattern starting with `~` does not round-trip. That is reported rather
633/// than hidden: [`branch_pattern_warning`] probes the compiled parser and
634/// names the loss.
635/// A pattern may also **freeze** a segment instead of writing it from a
636/// placeholder: `feat/#{issue}-{desc}` hardcodes the type, `{type}/#1-{desc}`
637/// hardcodes the issue number. Those are recovered as constants (see
638/// [`BranchParser::constants`]), because the previous release read them out of
639/// the branch name and dropping them would take gitmoji or auto-linking away
640/// from a repo that had them working.
641#[derive(Debug, Clone)]
642pub struct BranchParser {
643 re: Regex,
644 /// Segments the pattern freezes as literal text, in [`SEGMENTS`] order.
645 /// Never overlaps the regex's capture groups: a segment is either written
646 /// by a placeholder or frozen by a literal, never both.
647 constants: Vec<(&'static str, String)>,
648}
649
650impl BranchParser {
651 /// Compile `pattern` into a parser.
652 ///
653 /// `repo` must be the real repo name ([`crate::worktree::repo_name`]) and
654 /// `types` the repo's [`crate::config::Config::resolved_branch_types`] —
655 /// both feed the compiled regex, so a stand-in produces a parser that reads
656 /// a different repo's branches.
657 ///
658 /// Two patterns are refused rather than compiled into a parser that reads
659 /// back the wrong thing:
660 ///
661 /// 1. **A boundary between two capturing tokens that can move.** The
662 /// question is never "is there a separator" but "can the split between
663 /// these two land in more than one place", and [`boundary_can_shift`]
664 /// answers it from the charsets. Adjacent placeholders are only refused
665 /// when their alphabets overlap: `{issue}{desc}` reads `42` + `123-x`
666 /// back as `4212` + `3-x`, but `{type}{issue}` is safe, because `[a-z]+`
667 /// stops at the first digit and `\d+` at the first letter. A separator is
668 /// not required to sit outside the left token's charset either —
669 /// `{desc}-{issue}` round-trips, since `\d+` can never contain the `-`
670 /// that would have to reappear after the shift.
671 /// 2. **The same capturing token twice.** The formatter's `str::replace`
672 /// substitutes every occurrence, so `{desc}-{desc}` writes `foo-foo`;
673 /// reading that back needs a backreference, which this engine has not
674 /// got, and a second group of the same name will not compile.
675 ///
676 /// The rule as a whole is pinned by
677 /// `tests/naming_tests.rs::the_ambiguity_rule_accepts_exactly_the_patterns_that_round_trip`,
678 /// which enumerates the pattern space rather than sampling it: three
679 /// separate review findings landed here, and every one of them was a pattern
680 /// the hand-picked examples did not cover.
681 pub fn compile(pattern: &str, repo: &str, types: &[BranchType]) -> Result<Self> {
682 let mut re = String::from("^");
683 let mut seen: Vec<&str> = Vec::new();
684 // Literal text the *pattern author* wrote, kept for constant recovery.
685 // Deliberately not fed by `{repo}` / `{home}`: the user wrote `feat` in
686 // `feat/#{issue}-{desc}` and meant it, but nobody chose the repo's name
687 // for this purpose, and a repo that happens to be called `docs` must not
688 // turn `{repo}/{issue}-{desc}` into a docs-typed branch.
689 let mut authored = String::new();
690 // The last capture emitted, paired with every literal character emitted
691 // since — the separator the next capture would sit behind. An empty string
692 // means the two are adjacent, which a `{repo}` expanding to nothing
693 // achieves just as surely as writing them side by side, so the state
694 // follows the emitted *text* and not the token kind.
695 let mut pending: Option<(&'static str, String)> = None;
696 let mut rest = pattern;
697
698 while !rest.is_empty() {
699 // Find tokens the way `expand_placeholders` does — by searching for each
700 // one, not by scanning for `{`. The two disagree the moment a brace sits
701 // next to a placeholder: `str::replace` sees `{type}` at offset 1 of
702 // `{{type}` and writes `{feat`, whereas a `{`-scanner takes `{{type}` for
703 // one unknown token and demands that text back verbatim. Everything the
704 // formatter leaves alone — an unknown `{foo}`, an unbalanced brace — is
705 // literal here for the same reason.
706 let Some((at, token, group)) = TOKENS
707 .iter()
708 .filter_map(|(token, group)| rest.find(token).map(|at| (at, *token, *group)))
709 .min_by_key(|(at, ..)| *at)
710 else {
711 authored.push_str(rest);
712 push_literal(&mut re, rest, &mut pending);
713 break;
714 };
715 if at > 0 {
716 authored.push_str(&rest[..at]);
717 push_literal(&mut re, &rest[..at], &mut pending);
718 }
719 rest = &rest[at + token.len()..];
720
721 match group {
722 Some((name, group)) => {
723 // Before the boundary check, so `{desc}-{desc}` is diagnosed as the
724 // repeat it is rather than as a separator it could swallow.
725 if seen.contains(&name) {
726 return Err(GwmError::Config(format!(
727 "worktree.branch_pattern `{}` uses `{{{}}}` more than once; every occurrence expands \
728 to the same value, which cannot be read back",
729 sanitise_for_terminal(pattern),
730 name
731 )));
732 }
733 // The split between the previous capture and this one has to be the
734 // only one a branch name admits. Refusing here rather than reporting
735 // it later is the point: a mis-split is deterministic, so every probe
736 // agrees with it and the pattern would be declared valid while
737 // auto-linking targeted the wrong issue.
738 if let Some((left, sep)) = pending.as_ref() {
739 if boundary_can_shift(left, sep, name) {
740 return Err(GwmError::Config(if sep.is_empty() {
741 format!(
742 "worktree.branch_pattern `{}` puts `{{{}}}` straight after `{{{}}}` with nothing \
743 between them and both can hold the same characters, so a branch it writes cannot \
744 be read back unambiguously — separate them with a literal (`-`, `_`, `/`, …)",
745 sanitise_for_terminal(pattern),
746 name,
747 left
748 )
749 } else {
750 format!(
751 "worktree.branch_pattern `{}` separates `{{{}}}` from `{{{}}}` with `{}`, which \
752 could be read as part of either, so a branch it writes splits at the wrong place \
753 — separate them with a character neither can contain (`/`, `_`, `#`, `.`, …)",
754 sanitise_for_terminal(pattern),
755 left,
756 name,
757 sanitise_for_terminal(sep)
758 )
759 }));
760 }
761 }
762 seen.push(name);
763 re.push_str(group);
764 authored.push(segment_marker(name));
765 pending = Some((name, String::new()));
766 }
767 // `{repo}` / `{home}`: resolved by the formatter, so fixed text by the
768 // time a branch name exists. `{home}` is looked up lazily, since a
769 // pattern that does not use it must not fail to compile on a machine
770 // with no resolvable `$HOME`.
771 //
772 // Both break the literal run they sit in: they put real text between
773 // the literals either side, so those literals are not one token.
774 None => {
775 let text = if token == "{home}" {
776 dirs::home_dir()
777 .ok_or_else(|| GwmError::Config("cannot resolve $HOME".into()))?
778 .to_string_lossy()
779 .to_string()
780 } else {
781 repo.to_string()
782 };
783 authored.push(OPAQUE_MARKER);
784 push_literal(&mut re, &text, &mut pending);
785 }
786 }
787 }
788
789 re.push('$');
790 let re = Regex::new(&re).map_err(|e| {
791 GwmError::Config(format!(
792 "worktree.branch_pattern `{}` does not compile into a parser: {}",
793 sanitise_for_terminal(pattern),
794 e
795 ))
796 })?;
797 let constants = literal_constants(&authored, &seen, types);
798 let parser = Self { re, constants };
799 parser.mirrors_formatter(pattern, repo)?;
800 Ok(parser)
801 }
802
803 /// Check the parser just built against the formatter it is derived from.
804 ///
805 /// "The compiler mirrors `expand_placeholders`" was argued rather than
806 /// checked, and review found it wrong three times: a brace before a
807 /// placeholder, an expansion carrying another token, a token formed across
808 /// an expansion boundary. Each fix closed one instance and the next pass
809 /// found the next. So the property is verified instead — one probe branch
810 /// through the real formatter, read back with this parser — and a pattern
811 /// the two disagree on is refused rather than compiled into a parser that
812 /// recognises none of the branches it creates.
813 ///
814 /// Two of those three classes are gone since #494 made the formatter
815 /// single-pass: an expansion is a value now, so it cannot carry a token and
816 /// cannot form one against the text around it. The patterns that used to be
817 /// refused for it compile and round-trip. The check stays because its value
818 /// was never the list of known cases — it is what turns the next unknown one
819 /// into a refusal instead of a parser that reads nothing.
820 ///
821 /// Two deliberate exclusions:
822 ///
823 /// - **A `~` prefix.** `expand_placeholders` ends with `shellexpand::tilde`,
824 /// which no parser can undo. That divergence is known, documented, and
825 /// reported in full by [`branch_pattern_warning`]'s probe; refusing it
826 /// here would replace a verdict that names every affected feature with a
827 /// bare compile error.
828 /// - **A pattern the formatter itself cannot expand.** It fails at
829 /// `gwm create` with its own error, so refusing to build a parser for it
830 /// adds nothing.
831 fn mirrors_formatter(&self, pattern: &str, repo: &str) -> Result<()> {
832 if pattern.starts_with('~') {
833 return Ok(());
834 }
835 const PROBE: (&str, &str, &str) = ("feat", "42", "probe");
836 let Ok(written) = expand_placeholders(pattern, repo, Some(PROBE.0), Some(PROBE.1), Some(PROBE.2), None) else {
837 return Ok(());
838 };
839 // Only the segments this parser captures: one the pattern freezes comes
840 // back as its literal by design, and one it omits comes back empty.
841 let agrees = self.parse(&written).is_some_and(|spec| {
842 SEGMENTS.iter().all(|segment| {
843 !self.re.capture_names().flatten().any(|name| name == *segment)
844 || match *segment {
845 "type" => spec.type_ == PROBE.0,
846 "issue" => spec.issue == PROBE.1,
847 _ => spec.desc == PROBE.2,
848 }
849 })
850 });
851 if agrees {
852 return Ok(());
853 }
854 Err(GwmError::Config(format!(
855 "worktree.branch_pattern `{}` writes `{}`, which the parser derived from it does not read \
856 back, so gwm would recognise none of the branches this pattern creates",
857 sanitise_for_terminal(pattern),
858 sanitise_for_terminal(&written)
859 )))
860 }
861
862 /// The parser for a repo's effective config. The single lookup site that
863 /// pairs `worktree.branch_pattern` with `resolved_branch_types`, so no
864 /// caller has to remember they belong together.
865 ///
866 /// A pattern that cannot be compiled yields a parser that reads nothing
867 /// rather than one that reads the *default* shape: falling back to the
868 /// default is exactly the format/parse divergence this issue removes, and
869 /// it would put the wrong issue number on a branch instead of none. The
870 /// loud report belongs to `gwm doctor` / `gwm config validate`, which call
871 /// [`branch_pattern_warning`].
872 pub fn from_config(config: &crate::config::Config, repo: &str) -> Self {
873 let types = config.resolved_branch_types().types;
874 Self::compile(&config.worktree.branch_pattern, repo, &types).unwrap_or_else(|_| Self::inert())
875 }
876
877 /// The parser for whatever repo `repo` points at, loading its effective
878 /// config (repo layer over global, same as every other runtime read).
879 ///
880 /// For call sites that hold a repo handle but no [`crate::config::Config`].
881 /// Prefer [`Self::from_config`] when a config is already in hand, and hoist
882 /// this out of loops — it reads `.gwm.toml` and compiles a regex, so once
883 /// per listing rather than once per branch.
884 ///
885 /// A config that fails to load falls back to the built-in pattern. That is
886 /// the pre-#417 behaviour and it is the right one here: a `.gwm.toml` gwm
887 /// cannot read is a `.gwm.toml` gwm could not have created a worktree from
888 /// either, and it has its own diagnostic in `gwm doctor`.
889 pub fn for_repo(repo: &git2::Repository) -> Self {
890 let config = repo
891 .workdir()
892 .and_then(|wd| crate::config::Config::load_for_repo(wd).ok())
893 .unwrap_or_default();
894 Self::from_config(&config, &crate::worktree::repo_name(repo))
895 }
896
897 /// The built-in `{type}/#{issue}-{desc}` parser, for the entry points that
898 /// genuinely have no repo to read a config from — `gwm commit-prefix
899 /// --branch <name>` run outside a checkout is the only one.
900 pub fn builtin() -> &'static Self {
901 static BUILTIN: LazyLock<BranchParser> = LazyLock::new(|| {
902 BranchParser::compile(&crate::config::default_branch_pattern(), "", &default_branch_types())
903 .expect("the default branch_pattern compiles")
904 });
905 &BUILTIN
906 }
907
908 /// Does this parser recover `segment` (`type` / `issue` / `desc`) at all?
909 ///
910 /// True when the pattern writes it from a placeholder *or* freezes it as a
911 /// literal. False means no branch name the pattern produces can carry it: a
912 /// permanent absence rather than a parse that goes wrong.
913 pub fn reads_segment(&self, segment: &str) -> bool {
914 self.captures_segment(segment) || self.constants.iter().any(|(name, _)| *name == segment)
915 }
916
917 /// Does the pattern **write** `segment` from a placeholder, as opposed to
918 /// freezing it as literal text or omitting it?
919 ///
920 /// The distinction is what makes a value authoritative: a captured segment
921 /// carries whatever `gwm create` was given, a frozen one carries what the
922 /// pattern's author wrote whatever anyone asked for. See [`worktree_spec`].
923 pub fn captures_segment(&self, segment: &str) -> bool {
924 self.re.capture_names().flatten().any(|name| name == segment)
925 }
926
927 /// The segments this pattern freezes as literal text, `(segment, value)`.
928 ///
929 /// Disclosure, not decoration: `gwm doctor` names these on its OK line, so a
930 /// pattern that quietly pins every branch to one issue number says so rather
931 /// than looking like it read one out of the branch.
932 pub fn constants(&self) -> &[(&'static str, String)] {
933 &self.constants
934 }
935
936 /// A parser that matches nothing. `\z\A` can never match: it demands the
937 /// end of the haystack before its start.
938 fn inert() -> Self {
939 Self {
940 re: Regex::new(r"\z\A").expect("static inert regex compiles"),
941 constants: Vec::new(),
942 }
943 }
944
945 /// Recover the segments from a branch name, or `None` when the name was not
946 /// written by this pattern.
947 ///
948 /// A segment the pattern neither writes nor freezes comes back empty rather
949 /// than blocking the parse: `{type}/{desc}` carries no issue number, and
950 /// reporting the type and desc it *does* carry beats reporting nothing.
951 /// Callers that need a segment check it — see `gwm commit-prefix`, which is
952 /// defined in terms of the type and the issue and says so when either is
953 /// missing.
954 pub fn parse(&self, branch: &str) -> Option<BranchSpec> {
955 let cap = self.re.captures(branch)?;
956 let seg = |name: &str| {
957 cap
958 .name(name)
959 .map(|m| m.as_str().to_string())
960 .or_else(|| {
961 self
962 .constants
963 .iter()
964 .find(|(seg, _)| *seg == name)
965 .map(|(_, value)| value.clone())
966 })
967 .unwrap_or_default()
968 };
969 Some(BranchSpec {
970 type_: seg("type"),
971 issue: seg("issue"),
972 desc: seg("desc"),
973 })
974 }
975}
976
977/// Read the triple a worktree carries, from its branch **and** its directory
978/// name (issue #478).
979///
980/// `worktree.branch_pattern` and `worktree.path_pattern` need not carry the
981/// same segments, and when they do not, neither name holds the whole triple.
982/// Under `branch_pattern = "feat/#{issue}-{desc}"` with the default
983/// `path_pattern`, `gwm create fix 42 x` writes the branch `feat/#42-x` and
984/// the directory `fix-42-x`: the branch has no `{type}` to put `fix` into, so
985/// the directory is the only place that value still exists.
986///
987/// The branch wins for every segment it **writes from a placeholder** — it is
988/// the worktree's identity, and a directory renamed by hand or created under
989/// an older `path_pattern` must not rewrite it. The directory is consulted
990/// only for the rest: a segment the branch freezes as a literal, or omits.
991///
992/// `dirname` is the worktree's directory name, not its full path — the same
993/// thing [`BranchSpec::worktree_dirname`] produces. `None`, an empty name, a
994/// name `path_pattern` does not match, or a `path_pattern` that cannot be
995/// compiled all leave the branch's own reading untouched; a broken
996/// `path_pattern` has its own diagnostic and must not take the rename away.
997///
998/// `None` only when the branch itself does not parse — a free-form worktree
999/// (#416), which no triple describes.
1000pub fn worktree_spec(
1001 config: &crate::config::Config,
1002 repo: &str,
1003 branch: &str,
1004 dirname: Option<&str>,
1005) -> Option<BranchSpec> {
1006 let branch_parser = BranchParser::from_config(config, repo);
1007 let mut spec = branch_parser.parse(branch)?;
1008
1009 let types = config.resolved_branch_types().types;
1010 let from_path = dirname
1011 .filter(|name| !name.is_empty())
1012 .zip(BranchParser::compile(&config.worktree.path_pattern, repo, &types).ok())
1013 .and_then(|(name, parser)| parser.parse(name).map(|read| (parser, read)));
1014 let Some((path_parser, read)) = from_path else {
1015 return Some(spec);
1016 };
1017
1018 for segment in SEGMENTS {
1019 if branch_parser.captures_segment(segment) || !path_parser.captures_segment(segment) {
1020 continue;
1021 }
1022 let value = match segment {
1023 "type" => &read.type_,
1024 "issue" => &read.issue,
1025 _ => &read.desc,
1026 };
1027 if value.is_empty() {
1028 continue;
1029 }
1030 match segment {
1031 "type" => spec.type_ = value.clone(),
1032 "issue" => spec.issue = value.clone(),
1033 _ => spec.desc = value.clone(),
1034 }
1035 }
1036 Some(spec)
1037}
1038
1039/// Append fixed text to the regex under construction, escaping it, and record
1040/// it as part of the separator behind the capture still open — but only when
1041/// the text is actually non-empty. `{repo}` in a repo whose name failed to
1042/// resolve contributes nothing, and pretending it separated two groups would
1043/// let an ambiguous pattern through.
1044fn push_literal(re: &mut String, text: &str, pending: &mut Option<(&'static str, String)>) {
1045 if text.is_empty() {
1046 return;
1047 }
1048 // A separator may arrive in several chunks (`{issue}` `-` `{repo}` `-`
1049 // `{desc}`), so it accumulates rather than being decided by the first one.
1050 if let Some((_, sep)) = pending.as_mut() {
1051 sep.push_str(text);
1052 }
1053 re.push_str(®ex::escape(text));
1054}
1055
1056/// Can the split between two consecutive captures land in more than one place?
1057///
1058/// `left` and `right` are the captures and `sep` the literal text the pattern
1059/// puts between them, empty when they are adjacent. The formatter writes
1060/// `left · sep · right`; this asks whether some branch name it produces also
1061/// reads as a *different* pair.
1062///
1063/// With no separator the answer is yes as soon as one character can both end
1064/// `left` and start `right`: that character crosses the split. `{type}{issue}`
1065/// is therefore safe — `[a-z]+` stops at the first digit and `\d+` at the first
1066/// letter — while `{issue}{desc}` is not.
1067///
1068/// With a separator, moving the split `d` characters to the right means `left`
1069/// swallows `sep[..d]`, the separator then has to match `d` characters further
1070/// along — which requires `sep` to repeat with period `d` — and `right` has to
1071/// supply the `d` characters of separator that are no longer covered. All three
1072/// have to hold at once, which is what keeps `{type}-{issue}9-{desc}` legal
1073/// (`\d+` can eat the `9` but never the `-` that would have to follow it) while
1074/// `{type}-{issue}9{desc}` is refused.
1075///
1076/// Known ceiling: the last test approximates "what `right` can supply" by its
1077/// charset, which is exact only while the displaced separator is no longer than
1078/// the value beside it. No combination of the three segment charsets makes a
1079/// longer one reachable, and the enumeration in `tests/naming_tests.rs` is what
1080/// checks that claim rather than this comment.
1081fn boundary_can_shift(left: &str, sep: &str, right: &str) -> bool {
1082 let sep: Vec<char> = sep.chars().collect();
1083 if sep.is_empty() {
1084 return WITNESSES
1085 .iter()
1086 .any(|&c| segment_accepts(left, c) && segment_starts(right, c));
1087 }
1088 (1..=sep.len()).any(|d| {
1089 sep[..d].iter().all(|&c| segment_accepts(left, c))
1090 && sep[..sep.len() - d] == sep[d..]
1091 && segment_can_start_with(right, &sep[sep.len() - d..])
1092 })
1093}
1094
1095/// One character per class the three capture groups are built from. Every
1096/// charset is a union of these, so testing them decides any "can both sides
1097/// hold the same character" question without walking Unicode.
1098const WITNESSES: [char; 3] = ['a', '0', '-'];
1099
1100/// Can `segment` *begin* with `c`? Only `desc` differs from
1101/// [`segment_accepts`]: its tail allows the `-` its first character cannot be.
1102fn segment_starts(segment: &str, c: char) -> bool {
1103 match segment {
1104 "desc" => c.is_ascii_lowercase() || c.is_ascii_digit(),
1105 _ => segment_accepts(segment, c),
1106 }
1107}
1108
1109/// Can `segment` begin with the whole of `prefix`?
1110fn segment_can_start_with(segment: &str, prefix: &[char]) -> bool {
1111 match prefix.split_first() {
1112 None => true,
1113 Some((first, rest)) => segment_starts(segment, *first) && rest.iter().all(|&c| segment_accepts(segment, c)),
1114 }
1115}
1116
1117/// Can `segment` contain `c`? Mirrors the charsets the capture groups use, so
1118/// the boundary check asks about the same characters the regex would match.
1119fn segment_accepts(segment: &str, c: char) -> bool {
1120 match segment {
1121 "type" => c.is_ascii_lowercase(),
1122 "issue" => c.is_ascii_digit(),
1123 _ => c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-',
1124 }
1125}
1126
1127/// Recover the segments a pattern freezes as literal text instead of writing
1128/// from a placeholder.
1129///
1130/// `feat/#{issue}-{desc}` has no `{type}`, yet every branch it writes *is* a
1131/// `feat` branch, and the release before this one read that back because its
1132/// hardcoded regex happened to have a group in that position. Dropping it here
1133/// would take gitmoji, `[pr_template.by_type]` and `gwm commit-prefix` away
1134/// from a repo where they work today, so the literal is recovered on purpose.
1135///
1136/// `authored` is the literal text **the user wrote in the pattern**, with a
1137/// marker where each placeholder and each `{repo}` / `{home}` stood. The
1138/// expansions themselves are never in it: nobody picked the repo's name for
1139/// this, and a repo that happens to be called `docs` must not turn
1140/// `{repo}/{issue}-{desc}` into a docs-typed branch.
1141///
1142/// Recovery is **positional first, then oracle**, in that order — the reverse
1143/// lost both segments of `feat/#{issue}-fix`, where `feat` and `fix` are each
1144/// a configured branch type and neither is globally unique:
1145///
1146/// 1. A segment can only be recovered from the stretch of `authored` between
1147/// the placeholders it sits between. `{type}` before `{issue}`, `{desc}`
1148/// after it — so in `feat/#{issue}-fix` the type can only come from
1149/// `feat/#` and the description only from `-fix`.
1150/// 2. Within that stretch, candidates are the maximal runs of the segment's
1151/// own charset, and each is put to its own oracle: a branch type is an
1152/// exact match against the repo's configured list, an issue number is all
1153/// digits, a description is whatever `DESC_RE` accepts.
1154/// 3. A segment is recovered iff **every** maximal reading of the pattern
1155/// names it with the **same value**. Readings that disagree are a genuine
1156/// ambiguity — `feat/fix-{issue}-{desc}` names two configured types before
1157/// the issue — and inventing one would be worse than reporting none.
1158///
1159/// Rule 3 is stated per segment on purpose, and counting readings instead was
1160/// wrong twice. `feat/feat/#{issue}-{desc}` has two readings that both say
1161/// `feat`, which is one answer and not a coin toss; `feat/#{issue}-fix/done`
1162/// has two that disagree about the description while agreeing about the type,
1163/// and one ambiguous segment must not take an unanimous one down with it.
1164///
1165/// Segments are resolved in `type`, `issue`, `desc` order and each claim
1166/// advances a cursor, so `feat/#1-fixed`, which freezes all three in one
1167/// stretch, hands `feat` to the type, `1` to the issue and `fixed` to the
1168/// description rather than letting the loose `desc` oracle take the lot.
1169fn literal_constants(authored: &str, captured: &[&str], types: &[BranchType]) -> Vec<(&'static str, String)> {
1170 let missing: Vec<(usize, &'static str)> = SEGMENTS
1171 .iter()
1172 .enumerate()
1173 .filter(|(_, segment)| !captured.contains(segment))
1174 .map(|(rank, segment)| (rank, *segment))
1175 .collect();
1176
1177 let all = assignments(authored, &missing, types, 0);
1178 let best = all.iter().map(Vec::len).max().unwrap_or(0);
1179 let top: Vec<Vec<(&'static str, String)>> = all.into_iter().filter(|a| a.len() == best).collect();
1180
1181 // Unanimity, segment by segment. Keeping the first reading and dropping
1182 // whatever the others contradict is the whole rule: a segment survives when
1183 // every reading names it the same way, so the readings can disagree about
1184 // one segment without costing the others. `first` is already in `type`,
1185 // `issue`, `desc` order, which is the order callers expect back.
1186 let Some((first, rest)) = top.split_first() else {
1187 return Vec::new();
1188 };
1189 first
1190 .iter()
1191 .filter(|(segment, value)| {
1192 rest
1193 .iter()
1194 .all(|reading| reading.iter().any(|(s, v)| s == segment && v == value))
1195 })
1196 .cloned()
1197 .collect()
1198}
1199
1200/// Every way the missing segments could be read out of `authored`, in order.
1201///
1202/// Each segment may take a candidate from its own stretch or be skipped, and a
1203/// taken candidate moves the cursor so the next segment reads what is left.
1204/// Enumerating rather than deciding greedily is what separates the two cases
1205/// that look alike from the outside: `feat/#1-fix` freezes all three segments
1206/// and `fix` is a configured branch type, but only one reading assigns all
1207/// three (`fix` as the type leaves no digits for the issue), while
1208/// `feat/fix-{issue}-{desc}` has two readings of equal size and is therefore
1209/// genuinely ambiguous.
1210fn assignments(
1211 authored: &str,
1212 missing: &[(usize, &'static str)],
1213 types: &[BranchType],
1214 cursor: usize,
1215) -> Vec<Vec<(&'static str, String)>> {
1216 let Some(((rank, segment), rest)) = missing.split_first().map(|(head, tail)| (*head, tail)) else {
1217 return vec![Vec::new()];
1218 };
1219 // Skipping is always allowed: `feat/#{desc}` freezes a type and no issue
1220 // number, and losing the type because the issue has nowhere to come from
1221 // would be the regression this whole function exists to prevent.
1222 let mut out = assignments(authored, rest, types, cursor);
1223
1224 let (lower, upper) = segment_region(authored, rank);
1225 let lower = lower.max(cursor);
1226 if lower < upper {
1227 for (end, value) in charset_runs(&authored[lower..upper], segment) {
1228 let plausible = match segment {
1229 "type" => types.iter().any(|t| t.name == value),
1230 "issue" => ISSUE_RE.is_match(&value),
1231 _ => DESC_RE.is_match(&value),
1232 };
1233 if !plausible {
1234 continue;
1235 }
1236 for tail in assignments(authored, rest, types, lower + end) {
1237 let mut whole = vec![(segment, value.clone())];
1238 whole.extend(tail);
1239 out.push(whole);
1240 }
1241 }
1242 }
1243 out
1244}
1245
1246/// The stretch of `authored` a segment could have been written into: after
1247/// every placeholder that canonically precedes it, before the first that
1248/// follows. A placeholder the pattern does not carry leaves no marker, so it
1249/// bounds nothing.
1250fn segment_region(authored: &str, rank: usize) -> (usize, usize) {
1251 let lower = SEGMENTS[..rank]
1252 .iter()
1253 .filter_map(|earlier| {
1254 let marker = segment_marker(earlier);
1255 authored.find(marker).map(|at| at + marker.len_utf8())
1256 })
1257 .max()
1258 .unwrap_or(0);
1259 let upper = SEGMENTS[rank + 1..]
1260 .iter()
1261 .filter_map(|later| authored.find(segment_marker(later)))
1262 .min()
1263 .unwrap_or(authored.len());
1264 (lower, upper)
1265}
1266
1267/// Every maximal run of `segment`'s charset in `text`, as `(end offset, value)`.
1268///
1269/// A description's charset spans the `-` its first character cannot be, so a
1270/// run is trimmed of leading dashes rather than split on them: `-fixed--desc`
1271/// is one description, and so is `-fixed-`, both of which `DESC_RE` and the
1272/// 1.5.0 parser accept. Splitting on the dash dropped the first and truncated
1273/// the second.
1274fn charset_runs(text: &str, segment: &str) -> Vec<(usize, String)> {
1275 let mut out: Vec<(usize, String)> = Vec::new();
1276 let mut run: Option<usize> = None;
1277 for (at, c) in text.char_indices().chain(std::iter::once((text.len(), '\0'))) {
1278 if at < text.len() && segment_accepts(segment, c) {
1279 run.get_or_insert(at);
1280 continue;
1281 }
1282 if let Some(start) = run.take() {
1283 let value = text[start..at].trim_start_matches('-');
1284 if !value.is_empty() {
1285 out.push((at, value.to_string()));
1286 }
1287 }
1288 }
1289 out
1290}
1291
1292/// What each branch segment feeds, shared by both shapes of the warning.
1293///
1294/// Every segment feeds the TUI rename and `cmd_pr`'s template context on top
1295/// of its own headline consumer — naming only the headline would under-report
1296/// exactly what this warning promises to name. Two boundaries keep the claim
1297/// honest:
1298///
1299/// - `issue` is specifically *issue* linking. PR/MR detection goes through
1300/// `Forge::find_pr_for_branch`, which takes the whole branch name and never
1301/// parses it, so it keeps working whatever the pattern.
1302/// - hook placeholders break on the **remove / bootstrap** paths only. Those
1303/// rebuild the context with `HookContext::for_worktree`, which re-parses the
1304/// branch. `gwm create` uses `HookContext::for_create` and passes the
1305/// original `BranchSpec` straight through, so its own hooks keep the right
1306/// `type` / `issue` / `desc` however unreadable the pattern is.
1307fn segment_consumers(segment: &str) -> &'static str {
1308 match segment {
1309 "type" => "gitmoji / `gwm commit-prefix`, `[pr_template.by_type]` selection, remove/bootstrap hook placeholders and the TUI rename",
1310 "issue" => "issue auto-linking from the branch name, `gwm pr` body placeholders, remove/bootstrap hook placeholders and the TUI rename",
1311 _ => "`gwm pr` body placeholders, remove/bootstrap hook placeholders and the TUI rename",
1312 }
1313}
1314
1315/// How a segment's consumers fail when the pattern never writes it. Distinct
1316/// from the mis-parse verbs: nothing here reads the *wrong* value, there is
1317/// simply no value to read.
1318fn segment_absent_verb(segment: &str) -> &'static str {
1319 match segment {
1320 "type" => "have no branch type to work from",
1321 "issue" => "have no issue number to work from",
1322 _ => "have no description to work from",
1323 }
1324}
1325
1326/// Does `worktree.branch_pattern` survive a format/parse round-trip?
1327///
1328/// Issue #415 introduced this as damage assessment: the parser was hardcoded,
1329/// so most customised patterns broke, and the warning's job was to name which
1330/// features died. Issue #417 derived the parser from the pattern, which fixes
1331/// the cause. What is left for this predicate is the residue — the two things
1332/// a derived parser still cannot recover:
1333///
1334/// 1. **A pattern that cannot be compiled at all** (adjacent tokens, a
1335/// repeated token). [`BranchParser::from_config`] falls back to reading
1336/// nothing rather than to the built-in shape, deliberately; this is the
1337/// loud half of that silence.
1338/// 2. **A segment the pattern does not carry.** `{type}/{desc}` writes no
1339/// issue number, so nothing can read one back, and issue auto-linking is
1340/// genuinely inactive on that repo. Same for a pattern that hardcodes the
1341/// type: it is a legitimate convention, and stating what it costs is the
1342/// whole point of #415.
1343///
1344/// The check is an actual probe, never a comparison against the default
1345/// string: "differs from the default" and "breaks the parser" were never the
1346/// same set, and since #417 they barely overlap. `{type}-{issue}-{desc}`,
1347/// `{type}_{issue}_{desc}` and `{desc}-{issue}` are all customised and all
1348/// round-trip.
1349///
1350/// Returns the user-facing warning naming what actually breaks, or `None`
1351/// when the pattern round-trips. This is the single predicate both
1352/// `gwm doctor` and `gwm config validate` consume.
1353///
1354/// `repo` must be the real repo name ([`crate::worktree::repo_name`]) and
1355/// `types` the repo's [`crate::config::Config::resolved_branch_types`]: both
1356/// feed the compiled parser, so a stand-in returns a verdict about a
1357/// different repo's branches.
1358///
1359/// **Invariant: this function reports what it observed, and never
1360/// generalises.** Every message is phrased over "the N branch shapes probed".
1361/// The probe set is *classes worth probing*, not an exhaustive space; a class
1362/// it misses can only make the counts smaller, never make the statement
1363/// false. Since the parser is now derived, the probe's remaining job is to
1364/// catch what the compiler does not mirror — the `shellexpand::tilde` pass
1365/// the formatter ends with is the known one.
1366///
1367/// - `type` — every configured branch type. Finite, so this one *is*
1368/// exhaustive, and a type gwm would refuse to create is excluded.
1369/// - `issue` — `ISSUE_RE` is `\d+`: single-digit and multi-digit.
1370/// - `desc` — `DESC_RE` is `[a-z0-9][a-z0-9-]*`: a plain word, one carrying
1371/// the `-` it allows, one all-digits, and one that starts with digits and
1372/// then carries a `-`. Those last two are what tell an ambiguous adjacency
1373/// apart from a merely unusual separator.
1374pub fn branch_pattern_warning(pattern: &str, repo: &str, types: &[BranchType]) -> Option<String> {
1375 const ISSUES: [&str; 2] = ["7", "42"];
1376 const DESCS: [&str; 4] = ["probe", "probe-desc", "123", "123-probe"];
1377
1378 // A pattern that cannot be compiled reads *nothing* (see
1379 // `BranchParser::from_config`), so this is the loud half of that silence
1380 // rather than a separate class of problem. The compile error already names
1381 // the pattern and the reason.
1382 let parser = match BranchParser::compile(pattern, repo, types) {
1383 Ok(p) => p,
1384 Err(e) => return Some(format!("{}", e)),
1385 };
1386
1387 // A segment the pattern cannot supply at all is a different report from a
1388 // segment that reads back wrong, and conflating them was misleading in both
1389 // directions: saying "N of the shapes probed read back the wrong type" both
1390 // over-quantifies a permanent absence and hides the one-line fix. Ask the
1391 // compiled parser rather than re-scanning the pattern string, so the verdict
1392 // comes from the artefact that does the reading — and so a segment the
1393 // pattern *freezes* as a literal counts as supplied, because it is.
1394 let missing: Vec<&str> = SEGMENTS.into_iter().filter(|seg| !parser.reads_segment(seg)).collect();
1395
1396 let (mut unparseable, mut parsed, mut lossy) = (None::<String>, 0usize, 0usize);
1397 let (mut bad_type, mut bad_issue, mut bad_desc) = (false, false, false);
1398 let mut probes = 0usize;
1399
1400 // Probe only types `gwm create` would actually accept. `merge_layered`
1401 // deserialises `[[branch_types]]` without running `validate_branch_types`,
1402 // so the effective list can carry a name like `Feat` that the config
1403 // validator rejects outright — probing it would report the *default*
1404 // pattern as broken, because `BRANCH_RE`'s `[a-z]+` cannot match it. The
1405 // invalid config is reported by its own check; this one stays quiet
1406 // rather than blaming the pattern for it.
1407 let usable = types
1408 .iter()
1409 .map(|t| t.name.as_str())
1410 .filter(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_lowercase()));
1411
1412 for type_ in usable {
1413 for issue in ISSUES {
1414 for desc in DESCS {
1415 // A pattern that does not expand at all is a different, *loud*
1416 // failure: `gwm create` errors on it outright. Not our business.
1417 let formatted = expand_placeholders(pattern, repo, Some(type_), Some(issue), Some(desc), None).ok()?;
1418 probes += 1;
1419 match parser.parse(&formatted) {
1420 None => {
1421 unparseable.get_or_insert(formatted);
1422 }
1423 Some(back) => {
1424 parsed += 1;
1425 // A segment the pattern omits is reported by `missing`; counting
1426 // it here too would tally the same loss twice, in the shape that
1427 // describes it least well.
1428 let (t, i, d) = (
1429 !missing.contains(&"type") && back.type_ != type_,
1430 !missing.contains(&"issue") && back.issue != issue,
1431 !missing.contains(&"desc") && back.desc != desc,
1432 );
1433 // `lossy` counts probes, the flags accumulate across them. The
1434 // distinction matters: the flags say *which* segments can come
1435 // back wrong somewhere, `lossy` says *how many* shapes they came
1436 // back wrong on. Reporting the flags as if they held for every
1437 // parsed probe is the over-claim this counter exists to stop —
1438 // `{desc}/#{issue}-{type}` has probes that round-trip perfectly
1439 // alongside probes that swap two segments.
1440 lossy += usize::from(t || i || d);
1441 bad_type |= t;
1442 bad_issue |= i;
1443 bad_desc |= d;
1444 }
1445 }
1446 }
1447 }
1448 }
1449
1450 // No configured types at all would leave the verdict unprobed — say
1451 // nothing rather than guess. `resolved_branch_types` never yields this
1452 // (it falls back to the built-ins), so it is a guard, not a path.
1453 if probes == 0 {
1454 return None;
1455 }
1456
1457 let unparsed = probes - parsed;
1458 if missing.is_empty() && unparsed == 0 && lossy == 0 {
1459 return None;
1460 }
1461
1462 // Counts are always scoped to the shapes probed. There is no branch that
1463 // says "every branch created with this pattern", because that is precisely
1464 // the claim the probe set cannot support. The `missing` part is the one
1465 // exception, and it earns it: a placeholder the pattern does not contain
1466 // is absent from every name it will ever write, no probing required.
1467 let mut parts: Vec<String> = Vec::new();
1468 if !missing.is_empty() {
1469 let tokens = missing.iter().map(|s| format!("`{{{}}}`", s)).collect::<Vec<_>>();
1470 let losses = missing
1471 .iter()
1472 .map(|seg| format!("{} {}", segment_consumers(seg), segment_absent_verb(seg)))
1473 .collect::<Vec<_>>();
1474 parts.push(format!(
1475 "it carries no {}, so {} — write {} into the pattern to get them back",
1476 tokens.join(" and "),
1477 losses.join("; "),
1478 tokens.join(" and ")
1479 ));
1480 }
1481 if let Some(example) = unparseable {
1482 parts.push(format!(
1483 "{} 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)",
1484 unparsed,
1485 probes,
1486 sanitise_for_terminal(&example)
1487 ));
1488 }
1489 if lossy > 0 {
1490 let mut broken: Vec<String> = Vec::new();
1491 for (flag, seg, verb) in [
1492 (bad_type, "type", "read the wrong branch type"),
1493 (bad_issue, "issue", "target the wrong issue"),
1494 (bad_desc, "desc", "see the wrong description"),
1495 ] {
1496 if flag {
1497 broken.push(format!("`{}`, so {} {}", seg, segment_consumers(seg), verb));
1498 }
1499 }
1500 parts.push(format!(
1501 "{} of the {} branch shapes probed parse but read back {}",
1502 lossy,
1503 probes,
1504 broken.join("; ")
1505 ));
1506 }
1507
1508 Some(format!(
1509 "worktree.branch_pattern `{}` does not round-trip: {}",
1510 sanitise_for_terminal(pattern),
1511 parts.join("; and ")
1512 ))
1513}
1514
1515/// Neutralise control characters, and the display-reordering characters
1516/// [`is_display_reordering`] names, before echoing a config-supplied value on
1517/// a **single row**.
1518///
1519/// Config values come from a repo's `.gwm.toml`, and the commands that quote
1520/// them (`gwm config get` / `list`, `gwm types`, `gwm aliases list`,
1521/// `gwm doctor`, `gwm config validate`, `gwm commit-prefix`, and the TOFU
1522/// prompt itself) do not go through the trust gate, because inspecting a repo
1523/// you have not vetted is meant to be the safe thing to do. Echoing the raw
1524/// value would hand an untrusted `.gwm.toml` a terminal escape channel (an
1525/// OSC 52 clipboard write, a title rewrite, cursor games). Same idiom as
1526/// [`crate::tui::wt_tree::sanitize_name`]: replace, don't strip, so the value
1527/// stays recognisable and its length is not silently altered.
1528///
1529/// Line breaks are neutralised too, deliberately: a value that could emit a
1530/// `\n` could forge an extra row in a report the user reads as a list of
1531/// facts. Use [`sanitise_block_for_terminal`] for output that is *meant* to
1532/// span rows.
1533///
1534/// Public because every site that echoes a config-supplied string has to use
1535/// it, `main` included; a second copy would be a second thing to forget.
1536pub fn sanitise_for_terminal(s: &str) -> String {
1537 s.chars()
1538 .map(|c| {
1539 if c.is_control() || is_display_reordering(c) {
1540 '?'
1541 } else {
1542 c
1543 }
1544 })
1545 .collect()
1546}
1547
1548/// The characters carrying the Unicode `Bidi_Control` property (issue #502).
1549///
1550/// They are `Cf` (format), not `Cc` (control), so [`char::is_control`] does
1551/// not match them — which is the whole reason they need naming here. They
1552/// reorder how a terminal *renders* the text around them, so a value carrying
1553/// one can display a benign-looking command while the bytes that execute
1554/// differ. That defeats the same "what you read is what is there" guarantee
1555/// the control-character replacement exists to provide, so they are
1556/// neutralised the same way and at the same sinks.
1557///
1558/// The set is `Bidi_Control` exactly, not a subset: the overrides, embeddings
1559/// and isolates, **plus** the three implicit marks. The marks earn their place
1560/// on their bidi class rather than on being formatting characters. `U+061C` is
1561/// class `AL` and `U+200F` is `R`, so either one is a strong right-to-left
1562/// character inside otherwise left-to-right text, and UAX #9's weak and
1563/// neutral rules then reorder the digits and punctuation around it: an
1564/// argument or a URL can render in an order the bytes do not have. `U+200E`
1565/// is class `L` and does the same in a value whose paragraph direction is
1566/// right-to-left. Aligning on the named property rather than on a hand-picked
1567/// list is also what makes this reviewable.
1568///
1569/// Visible to the crate because `aliases::validate_aliases` needs the same set
1570/// at the one boundary the sinks cannot reach: an alias expansion becomes argv
1571/// before clap parses it, so it is refused there rather than cleaned here,
1572/// exactly as the control characters are.
1573pub(crate) fn is_display_reordering(c: char) -> bool {
1574 matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
1575}
1576
1577/// Neutralise control characters in output whose **shape is rows**: a `toml`
1578/// parse diagnostic with its caret-under-the-column snippet, or the raw
1579/// `.gwm.toml` body the TOFU prompt shows on `show` (issue #473).
1580///
1581/// Keeps `\n` and `\t`, which carry the layout, and replaces everything else
1582/// including `\r` (which returns the cursor to column zero and lets a value
1583/// overwrite the line it was printed on) and the display-reordering characters
1584/// [`is_display_reordering`] names. Sanitising these is not "breaking raw": an
1585/// escape sequence inside the body defeats the very inspection the `show` view
1586/// exists to provide.
1587/// Neutralise a **diagnostic** headed for stderr: block-sanitise it, then
1588/// indent every line after the first (issue #473).
1589///
1590/// The indent is the security part, not cosmetics. A diagnostic is one string
1591/// that mixes layout `toml` generated with values it decoded out of the repo's
1592/// file, and no classification can separate them: the same message carries a
1593/// caret-under-the-column snippet AND an ``unknown field `<key>` `` naming a
1594/// key the repo chose. Measured, a key written as `"bad\nerror: forged"`
1595/// printed a second line at column zero reading exactly like a statement from
1596/// gwm.
1597///
1598/// Owning the left margin makes that impossible without classifying anything:
1599/// only the first line starts at column zero, and `\r` is already replaced, so
1600/// nothing the config emits can get back there. The snippet stays readable,
1601/// which is the whole reason the line breaks survive at all.
1602pub fn sanitise_diagnostic_for_terminal(s: &str) -> String {
1603 let cleaned = sanitise_block_for_terminal(s);
1604 let mut out = String::with_capacity(cleaned.len());
1605 for (i, line) in cleaned.split('\n').enumerate() {
1606 if i > 0 {
1607 out.push_str("\n ");
1608 }
1609 out.push_str(line);
1610 }
1611 out
1612}
1613
1614pub fn sanitise_block_for_terminal(s: &str) -> String {
1615 // A CRLF pair is a line ending, so it normalises to `\n` rather than losing
1616 // its `\r` to a `?`. Windows writes config files, ledgers and process output
1617 // that way, and marking every one of their line ends as suspicious would
1618 // make the neutralisation itself look like the corruption.
1619 //
1620 // A LONE `\r` is not a line ending: it returns the cursor to column zero and
1621 // lets what follows overwrite the line already printed, which is the one
1622 // thing the margin rule exists to prevent. That one still goes.
1623 let normalised = s.replace("\r\n", "\n");
1624 normalised
1625 .chars()
1626 .map(|c| {
1627 if (c.is_control() && c != '\n' && c != '\t') || is_display_reordering(c) {
1628 '?'
1629 } else {
1630 c
1631 }
1632 })
1633 .collect()
1634}
1635
1636pub fn kebab(input: &str) -> String {
1637 // Lowercase, then collapse every non-alphanumeric run into a single `-`.
1638 let lower = input.to_lowercase();
1639 let mut out = String::with_capacity(lower.len());
1640 let mut prev_dash = false;
1641 for c in lower.chars() {
1642 if c.is_ascii_alphanumeric() {
1643 out.push(c);
1644 prev_dash = false;
1645 } else if !prev_dash && !out.is_empty() {
1646 out.push('-');
1647 prev_dash = true;
1648 }
1649 }
1650 out.trim_matches('-').to_string()
1651}