diff --git a/crates/db/.sqlx/query-98a95291b9fae8d7f1cacb7916cb6c86928dc6277fed7f0d9b2cb72fbf7bff13.json b/crates/db/.sqlx/query-98a95291b9fae8d7f1cacb7916cb6c86928dc6277fed7f0d9b2cb72fbf7bff13.json new file mode 100644 index 0000000000..f12b882a71 --- /dev/null +++ b/crates/db/.sqlx/query-98a95291b9fae8d7f1cacb7916cb6c86928dc6277fed7f0d9b2cb72fbf7bff13.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE workspaces SET name = $1, updated_at = datetime('now', 'subsec') WHERE id = $2 AND name IS $3", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "98a95291b9fae8d7f1cacb7916cb6c86928dc6277fed7f0d9b2cb72fbf7bff13" +} diff --git a/crates/db/src/models/workspace.rs b/crates/db/src/models/workspace.rs index 63e2c305dc..f8d138b3ae 100644 --- a/crates/db/src/models/workspace.rs +++ b/crates/db/src/models/workspace.rs @@ -380,6 +380,31 @@ impl Workspace { Ok(result.rows_affected() > 0) } + /// Compare-and-set name update: only sets `name` if it still equals + /// `expected_old` (NULL-safe via SQLite `IS`). Returns `true` if a row was + /// updated (the name was unchanged since the caller snapshotted it), `false` + /// if it changed concurrently. Lets the async generated-title write avoid + /// clobbering a manual rename (or an MCP-provided name) that landed in the + /// meantime. `expected_old` must be the pre-spawn workspace-row snapshot + /// name, NOT a value re-derived from the prompt (they can differ). + pub async fn update_name_if_unchanged( + pool: &SqlitePool, + workspace_id: Uuid, + new_name: &str, + expected_old: Option<&str>, + ) -> Result { + let result = sqlx::query!( + "UPDATE workspaces SET name = $1, updated_at = datetime('now', 'subsec') WHERE id = $2 AND name IS $3", + new_name, + workspace_id, + expected_old, + ) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) + } + /// Find workspace by path using container-ref path containment. /// Used by clients that may open a repo subfolder rather than the workspace root. pub async fn resolve_container_ref_by_prefix( diff --git a/crates/executors/src/title_gen.rs b/crates/executors/src/title_gen.rs index c0b6804250..52efa53b44 100644 --- a/crates/executors/src/title_gen.rs +++ b/crates/executors/src/title_gen.rs @@ -29,7 +29,7 @@ struct RawNames { branch: String, } -const TITLE_MAX_CHARS: usize = 72; +const TITLE_MAX_CHARS: usize = 48; const BRANCH_MAX_CHARS: usize = 40; const PROMPT_MAX_CHARS: usize = 2000; // claude's startup (loading the user's possibly-large ~/.claude.json) can take @@ -37,6 +37,110 @@ const PROMPT_MAX_CHARS: usize = 2000; // stuck call still falls back to heuristic naming. const CALL_TIMEOUT: Duration = Duration::from_secs(20); +/// Arrow first lines get extra slack over the 48-char short-line bound +/// (matching the frontend's 100-char first-line name cap), but an arbitrarily +/// long arrow line — e.g. a pasted log line containing " -> " — is a task, not +/// a title, and must not be stored verbatim as the workspace name. +const HINT_ARROW_MAX_CHARS: usize = 100; + +/// Rewrite every unicode arrow (" → ") to the ASCII form (" -> "). +/// `String::replace` alone is non-overlapping ("a → → b" would keep its second +/// arrow because the shared space is consumed by the first match), so loop +/// until stable; each pass strictly reduces the '→' count, so it terminates. +fn normalize_arrows(s: &str) -> String { + let mut out = s.to_string(); + while out.contains(" → ") { + out = out.replace(" → ", " -> "); + } + out +} + +/// Normalize a candidate title into the stored form shared by the hint and +/// model paths: collapse whitespace, normalize the unicode arrow, cap at +/// `max_chars`, and trim trailing sentence punctuation — a mid-word cut (or a +/// model-emitted period) must never leave dangling punctuation, whitespace, or +/// an arrow fragment at the tail. Length *policy* stays per-path (the hint +/// path rejects overlong arrow lines up front; the model path caps at +/// [`TITLE_MAX_CHARS`]). +fn canonicalize_title(raw: &str, max_chars: usize) -> String { + let joined = normalize_arrows(&raw.split_whitespace().collect::>().join(" ")); + let capped: String = joined.chars().take(max_chars).collect(); + // A cap cut inside " -> " (or degenerate input like "a -> ->") leaves + // dangling arrow fragments, possibly shielding more trailing punctuation + // ("word. ->"). Trim punctuation and arrow fragments to a fixpoint — each + // pass strictly shortens the string, so the loop terminates. + let mut tail = capped.as_str(); + loop { + let before = tail; + tail = tail.trim_end_matches(['.', ',', ':', ';', ' ']); + tail = tail + .strip_suffix(" ->") + .or_else(|| tail.strip_suffix(" -")) + .unwrap_or(tail); + if tail.len() == before.len() { + break; + } + } + tail.to_string() +} + +/// If the user's first line already reads like a deliberate title, return it +/// (lightly normalized) so we keep it verbatim instead of asking the model. +/// +/// Signals: contains " -> " (Miguel's explicit format) at a plausible title +/// length, OR is short (<= 8 words AND <= 48 chars) AND the message has a body +/// after it (a lone short message is a task, not a title hint — still +/// model-styled) unless it contains an arrow. A trailing clause punctuation +/// (":", ",", "..." or the unicode "…") rejects, since it signals the first +/// line is the start of a sentence, not a title. +pub fn first_line_title_hint(message: &str) -> Option { + let mut lines = message.trim().lines(); + let raw_first = lines.next()?.trim(); + // Strip a Markdown ATX heading marker (1-6 leading '#' followed by + // whitespace or end of line, per CommonMark); keep issue-ref-style + // prefixes like "#27 -> ..." and 7+-hash runs intact — those '#' are + // content, not markup. + let hashes = raw_first.len() - raw_first.trim_start_matches('#').len(); + let first = if (1..=6).contains(&hashes) { + if hashes == raw_first.len() { + "" + } else if raw_first[hashes..].starts_with(char::is_whitespace) { + raw_first[hashes..].trim_start() + } else { + raw_first + } + } else { + raw_first + }; + if first.is_empty() { + return None; + } + // Collapse whitespace and normalize unicode arrows BEFORE measuring, so + // acceptance bounds exactly the form that gets stored (runs of spaces + // shrink; " → " grows into " -> "). + let first = normalize_arrows(&first.split_whitespace().collect::>().join(" ")); + let char_count = first.chars().count(); + let has_arrow = first.contains(" -> ") && char_count <= HINT_ARROW_MAX_CHARS; + let has_body = lines.any(|l| !l.trim().is_empty()); + let word_count = first.split_whitespace().count(); + let short_enough = word_count <= 8 && char_count <= 48; + if !(has_arrow || (short_enough && has_body)) { + return None; + } + if first.ends_with(':') + || first.ends_with(',') + || first.ends_with("...") + || first.ends_with('…') + { + return None; + } + // Acceptance already bounds the line, so the cap here is a no-op backstop; + // canonicalization can strip a line down to nothing (e.g. "."), so + // re-check emptiness before declaring it a title. + let title = canonicalize_title(&first, HINT_ARROW_MAX_CHARS); + (!title.is_empty()).then_some(title) +} + /// Ask Claude Haiku for a concise title + branch slug describing `first_message`. /// Returns `None` on any failure (spawn error, non-zero exit, timeout, or /// unparsable output) so the caller can fall back to heuristic naming. @@ -47,11 +151,36 @@ pub async fn generate_workspace_names(first_message: &str) -> Option -> ' where keyword is the product, repo, service, \ + or person the task is about (infer it from the task text: repo names, \ + project codenames, people, tools), and gist is 2-5 words.\n\ + - Hard cap: 40 characters total.\n\ + - Lowercase everything except proper nouns and code identifiers.\n\ + - Terse noun fragments, never sentences. No trailing period. \ + Never start with 'Fix bug where', 'Implement', 'Update the', or similar prose.\n\ + - If the task's FIRST LINE already looks like a title (8 words or fewer, \ + not a full sentence, no trailing verb clause), reuse that line as the title \ + verbatim (only trim trailing punctuation) instead of inventing one.\n\ + \n\ + Good titles:\n\ + - bp -> runflow dogfood\n\ + - sentinel -> throughput fixes\n\ + - bp -> customer.io migration\n\ + - patri -> main chief\n\ + \n\ + Bad titles (never do this):\n\ + - Fix bug where b2b companies cannot start an order\n\ + - Implement CSV export for the reports page\n\ + \n\ + Branch rules: lowercase kebab-case, at most 30 chars, hyphens only, \ + no slashes, no arrows — a descriptive slug of the task \ + (e.g. 'bp-customerio-migration', 'sentinel-throughput-fixes').\n\ + \n\ + Task:\n{task}" ); // Reuse the executor's pinned claude CLI (`npx -y @anthropic-ai/claude-code@X`), @@ -120,16 +249,7 @@ fn parse_workspace_names(stdout: &str) -> Option { } let raw: RawNames = serde_json::from_str(&stdout[start..=end]).ok()?; - let title: String = raw - .title - .split_whitespace() - .collect::>() - .join(" ") - .chars() - .take(TITLE_MAX_CHARS) - .collect::() - .trim() - .to_string(); + let title = canonicalize_title(&raw.title, TITLE_MAX_CHARS); if title.is_empty() { return None; } @@ -170,7 +290,7 @@ fn slugify(input: &str, max: usize) -> String { #[cfg(test)] mod tests { - use super::{parse_workspace_names, slugify}; + use super::{TITLE_MAX_CHARS, first_line_title_hint, parse_workspace_names, slugify}; #[test] fn parses_fenced_json() { @@ -180,6 +300,162 @@ mod tests { assert_eq!(n.branch_slug, "add-csv-export-reports"); } + #[test] + fn hint_arrow_wins_even_without_body() { + assert_eq!( + first_line_title_hint("patri -> main chief").as_deref(), + Some("patri -> main chief") + ); + assert_eq!( + first_line_title_hint("bp -> runflow dogfood\n\ndetails here").as_deref(), + Some("bp -> runflow dogfood") + ); + } + + #[test] + fn hint_normalizes_unicode_arrow_and_trims_period() { + assert_eq!( + first_line_title_hint("bp → customer.io migration.").as_deref(), + Some("bp -> customer.io migration") + ); + } + + #[test] + fn hint_short_first_line_with_body_wins() { + assert_eq!( + first_line_title_hint("Fix mobile scroll\n\nthe terminal jumps").as_deref(), + Some("Fix mobile scroll") + ); + } + + #[test] + fn hint_strips_leading_markdown_heading() { + assert_eq!( + first_line_title_hint("# bp -> runflow dogfood").as_deref(), + Some("bp -> runflow dogfood") + ); + assert_eq!( + first_line_title_hint("### bp -> runflow dogfood").as_deref(), + Some("bp -> runflow dogfood") + ); + // A bare hash run is a heading marker with no content. + assert!(first_line_title_hint("###\n\nbody").is_none()); + // 7+ hashes are not an ATX heading (CommonMark) — kept as content. + assert_eq!( + first_line_title_hint("####### bp -> runflow dogfood").as_deref(), + Some("####### bp -> runflow dogfood") + ); + } + + #[test] + fn hint_measures_length_on_collapsed_whitespace() { + // Interior space runs collapse before the length check, so a line + // whose RAW form exceeds the arrow cap but whose stored form is short + // is still accepted. + let padded = format!("bp{}->{}dogfood", " ".repeat(50), " ".repeat(50)); + assert!(padded.chars().count() > 100); + assert_eq!( + first_line_title_hint(&padded).as_deref(), + Some("bp -> dogfood") + ); + } + + #[test] + fn hint_keeps_issue_ref_hash_prefix() { + // '#' not followed by whitespace is content (issue ref), not markup. + assert_eq!( + first_line_title_hint("#27 -> short arrow titles").as_deref(), + Some("#27 -> short arrow titles") + ); + } + + #[test] + fn hint_rejects_long_sentence_and_lone_short_line() { + // 9 words, no body, no arrow -> not a title hint. + assert!( + first_line_title_hint("Fix bug where b2b companies cannot start an order").is_none() + ); + // Short but no body and no arrow -> a task, not a title. + assert!(first_line_title_hint("Fix mobile scroll").is_none()); + } + + #[test] + fn hint_rejects_trailing_clause_punctuation() { + assert!(first_line_title_hint("Please do the following:\n\n- a\n- b").is_none()); + assert!(first_line_title_hint("add this, and that,\n\nbody").is_none()); + assert!(first_line_title_hint("Do these things...\n\nbody").is_none()); + // Unicode ellipsis (smart-punctuation form of "...") rejects too. + assert!(first_line_title_hint("Do these things…\n\nbody").is_none()); + } + + #[test] + fn hint_rejects_empty() { + assert!(first_line_title_hint("").is_none()); + assert!(first_line_title_hint("\n\n").is_none()); + // Canonicalization can strip a line to nothing — never store "". + assert!(first_line_title_hint(".\n\nbody").is_none()); + } + + #[test] + fn hint_normalizes_like_the_model_path() { + // Whitespace collapse and trailing-punctuation trim match the stored + // form the Haiku path produces for the same string. + assert_eq!( + first_line_title_hint("bp -> runflow dogfood\n\nbody").as_deref(), + Some("bp -> runflow dogfood") + ); + assert_eq!( + first_line_title_hint("Fix scroll bug;\n\nbody").as_deref(), + Some("Fix scroll bug") + ); + } + + #[test] + fn hint_rejects_overlong_arrow_line() { + // A pasted log line containing " -> " is a task, not a title — it must + // fall through to the model path instead of being stored verbatim. + let long_arrow = format!("request -> response {}", "x".repeat(120)); + assert!(first_line_title_hint(&long_arrow).is_none()); + assert!(first_line_title_hint(&format!("{long_arrow}\n\nbody")).is_none()); + // At the boundary (100 chars) the arrow line is still accepted. + let at_cap = format!("bp -> {}", "y".repeat(94)); // 6 + 94 = 100 chars + assert_eq!(at_cap.chars().count(), 100); + assert_eq!( + first_line_title_hint(&at_cap).as_deref(), + Some(at_cap.as_str()) + ); + // The bound is measured AFTER unicode-arrow normalization (" → " grows + // into " -> "), so a 100-char unicode-arrow line that would exceed the + // cap once normalized is rejected, never silently truncated. + let unicode_at_raw_cap = format!("bp → {}", "y".repeat(95)); // 100 raw, 101 normalized + assert_eq!(unicode_at_raw_cap.chars().count(), 100); + assert!(first_line_title_hint(&unicode_at_raw_cap).is_none()); + let unicode_fits = format!("bp → {}", "y".repeat(94)); // 100 once normalized + assert_eq!( + first_line_title_hint(&unicode_fits).as_deref(), + Some(format!("bp -> {}", "y".repeat(94)).as_str()) + ); + } + + #[test] + fn hint_normalizes_adjacent_unicode_arrows() { + // String::replace is non-overlapping; the stable loop converts both. + assert_eq!( + first_line_title_hint("a → → b\n\nbody").as_deref(), + Some("a -> -> b") + ); + } + + #[test] + fn hint_keeps_cjk_title_verbatim() { + // A non-ASCII hint is a fine NAME; the un-sluggable branch seed is + // guarded at the rename site (empty slug -> heuristic branch kept). + assert_eq!( + first_line_title_hint("修复登录\n\n详情").as_deref(), + Some("修复登录") + ); + } + #[test] fn sanitizes_slashes_and_caps_length() { let s = "{\"title\":\"Fix login redirect loop on Safari\",\"branch\":\"fix/safari-login-redirect-loop-and-more-tail\"}"; @@ -204,6 +480,66 @@ mod tests { assert_eq!(parse_workspace_names(s).unwrap().title, "Add export"); } + #[test] + fn keeps_arrow_in_title_and_slugifies_branch() { + let s = + "{\"title\":\"bp -> customer.io migration\",\"branch\":\"bp-customerio-migration\"}"; + let n = parse_workspace_names(s).unwrap(); + assert_eq!(n.title, "bp -> customer.io migration"); + assert!(!n.branch_slug.contains('>')); + assert!(!n.branch_slug.contains(' ')); + } + + #[test] + fn normalizes_unicode_arrow_in_title() { + let s = "{\"title\":\"bp → runflow dogfood\",\"branch\":\"bp-runflow-dogfood\"}"; + assert_eq!( + parse_workspace_names(s).unwrap().title, + "bp -> runflow dogfood" + ); + } + + #[test] + fn caps_title_at_max_and_trims_trailing_punctuation() { + let long = "a".repeat(60); + let s = format!("{{\"title\":\"{long}\",\"branch\":\"x\"}}"); + let title = parse_workspace_names(&s).unwrap().title; + assert!(title.chars().count() <= TITLE_MAX_CHARS); + } + + #[test] + fn cap_cut_never_leaves_dangling_arrow_fragment() { + // 45-char keyword + " -> gist" = 53 chars; the 48-char cap cuts inside + // the arrow, which must be stripped rather than stored as "... ->". + let keyword = "a".repeat(45); + let s = format!("{{\"title\":\"{keyword} -> gist\",\"branch\":\"x\"}}"); + let title = parse_workspace_names(&s).unwrap().title; + assert_eq!(title, keyword); + // One char shorter cut ends in " -" — also stripped. + let keyword = "a".repeat(46); + let s = format!("{{\"title\":\"{keyword} -> gist\",\"branch\":\"x\"}}"); + let title = parse_workspace_names(&s).unwrap().title; + assert_eq!(title, keyword); + // Chained fragments trim to a fixpoint, not just one pass. + let s = "{\"title\":\"a -> -> \",\"branch\":\"x\"}"; + assert_eq!(parse_workspace_names(s).unwrap().title, "a"); + let s = "{\"title\":\"fix -> a - -\",\"branch\":\"x\"}"; + assert_eq!(parse_workspace_names(s).unwrap().title, "fix -> a"); + // A 43-char keyword + \" -> -> b\" cap-cut at 48 chains two fragments. + let keyword = "a".repeat(43); + let s = format!("{{\"title\":\"{keyword} -> -> b\",\"branch\":\"x\"}}"); + assert_eq!(parse_workspace_names(&s).unwrap().title, keyword); + } + + #[test] + fn strips_trailing_period_from_title() { + let s = "{\"title\":\"sentinel -> throughput fixes.\",\"branch\":\"x\"}"; + assert_eq!( + parse_workspace_names(s).unwrap().title, + "sentinel -> throughput fixes" + ); + } + #[test] fn rejects_non_json_and_empty_title() { assert!(parse_workspace_names("sorry, I can't help with that").is_none()); @@ -216,5 +552,11 @@ mod tests { assert_eq!(slugify("feature/Foo Bar", 40), "feature-foo-bar"); assert_eq!(slugify("a".repeat(50).as_str(), 8), "aaaaaaaa"); assert_eq!(slugify("---", 40), ""); + // An arrow title degrades to a clean branch slug (no '>', no spaces). + assert_eq!( + slugify("bp -> customer.io migration", 40), + "bp-customer-io-migration" + ); + assert_eq!(slugify("bp -> runflow dogfood", 40), "bp-runflow-dogfood"); } } diff --git a/crates/server/src/routes/workspaces/create.rs b/crates/server/src/routes/workspaces/create.rs index 19c00943c8..b2ba0af28c 100644 --- a/crates/server/src/routes/workspaces/create.rs +++ b/crates/server/src/routes/workspaces/create.rs @@ -338,33 +338,69 @@ pub async fn create_and_start_workspace( ))) } -/// Background best-effort: ask Haiku for a concise title + branch slug from the -/// first message, apply the title, and rename the branch IF it is still the -/// original heuristic one (compare-and-set, so a manual rename always wins). -/// Any failure leaves the heuristic name/branch and is logged. Spawned only -/// after the worktree exists, so the git rename can't race worktree creation. +/// Background best-effort naming. Two paths: +/// +/// 1. If the first line already reads like a deliberate title (Miguel's +/// `keyword -> gist` format, or a short first line with a body), we KEEP it +/// verbatim and derive the branch deterministically from it — no model call, +/// so hinted creations are instant. +/// 2. Otherwise we ask Haiku for a concise `keyword -> gist` title + branch slug. +/// +/// Either way the name write is compare-and-set on the pre-spawn snapshot name +/// and the branch rename is compare-and-set on the heuristic branch, so a manual +/// rename (or an MCP-provided name) that landed in the window always wins. Any +/// failure leaves the heuristic name/branch and is logged. Spawned only after +/// the worktree exists, so the git rename can't race worktree creation. async fn apply_generated_workspace_names( deployment: &DeploymentImpl, workspace: &Workspace, first_message: &str, ) { + // Path 1: a deliberate first-line title hint wins — keep it verbatim, no + // model call, branch derived deterministically from the hint. + if let Some(hint) = executors::title_gen::first_line_title_hint(first_message) { + apply_name_and_branch(deployment, workspace, &hint, &hint).await; + return; + } + + // Path 2: ask Haiku for a `keyword -> gist` title + branch slug. let Some(names) = executors::title_gen::generate_workspace_names(first_message).await else { return; }; + apply_name_and_branch(deployment, workspace, &names.title, &names.branch_slug).await; +} + +/// Compare-and-set the workspace `name` to `title` (only if it still equals the +/// pre-spawn snapshot) and rename the branch from `branch_seed` (only if the +/// branch is still the heuristic one). Shared by the hint and Haiku paths. +async fn apply_name_and_branch( + deployment: &DeploymentImpl, + workspace: &Workspace, + title: &str, + branch_seed: &str, +) { let pool = &deployment.db().pool; - if let Err(e) = - Workspace::update(pool, workspace.id, None, None, Some(names.title.as_str())).await + // Compare-and-set on the name: only overwrite if it is still the value the + // workspace was created with (the snapshot). A manual rename or an + // MCP-provided name that landed in the window is NULL-safe-preserved. + match Workspace::update_name_if_unchanged(pool, workspace.id, title, workspace.name.as_deref()) + .await { - tracing::warn!( + Ok(false) => tracing::debug!( + "Skipping generated title for {}: name already changed", + workspace.id + ), + Ok(true) => {} + Err(e) => tracing::warn!( "Failed to apply generated title to workspace {}: {e}", workspace.id - ); + ), } // Compare-and-set: only rename if the branch is still the heuristic one the // workspace was created with. The user may have manually renamed it in the - // 5-14s Haiku window, and we must not clobber that. + // naming window, and we must not clobber that. let current = match Workspace::find_by_id(pool, workspace.id).await { Ok(Some(ws)) => ws, Ok(None) => return, // workspace deleted meanwhile @@ -384,10 +420,23 @@ async fn apply_generated_workspace_names( return; } + // `git_branch_from_workspace_with_len` re-slugifies the seed, so passing a + // raw title containing " -> " is safe (it degrades to a hyphenated slug). let new_branch = deployment .container() - .git_branch_from_workspace_with_len(&workspace.id, &names.branch_slug, 40) + .git_branch_from_workspace_with_len(&workspace.id, branch_seed, 40) .await; + // An un-sluggable seed (all-CJK/emoji/punctuation hint) yields an empty + // slug and a degenerate "/-" branch — keep the heuristic + // branch instead, mirroring the model path's empty-branch_slug bail-out. + // (A non-empty slug never ends in '-': git_branch_id_with_len trims it.) + if new_branch.ends_with('-') { + tracing::debug!( + "Skipping generated branch rename for {}: seed yields empty slug", + workspace.id + ); + return; + } if new_branch == current.branch { return; } diff --git a/crates/utils/src/text.rs b/crates/utils/src/text.rs index ae726d3229..48ba0037ba 100644 --- a/crates/utils/src/text.rs +++ b/crates/utils/src/text.rs @@ -65,4 +65,36 @@ mod tests { assert_eq!(truncate_to_char_boundary(input, 5), "🔥"); assert_eq!(truncate_to_char_boundary(input, 3), ""); } + + #[test] + fn arrow_titles_slugify_to_git_safe_branch() { + use super::git_branch_id_with_len; + + // A " -> " title (space, dash, greater-than are all invalid in git refs) + // must degrade to a clean hyphenated slug. + assert_eq!( + git_branch_id_with_len("bp -> runflow dogfood", 40), + "bp-runflow-dogfood" + ); + assert_eq!( + git_branch_id_with_len("bp -> customer.io migration", 40), + "bp-customer-io-migration" + ); + assert_eq!( + git_branch_id_with_len("patri → main chief", 40), + "patri-main-chief" + ); + } + + #[test] + fn unsluggable_input_yields_empty_slug() { + use super::git_branch_id_with_len; + + // Pins the premise behind the generated-branch-rename guard: inputs + // with no ASCII alphanumerics slug to "" (never to a bare "-"), so a + // degenerate "/-" branch is detectable via ends_with('-'). + assert_eq!(git_branch_id_with_len("修复登录", 40), ""); + assert_eq!(git_branch_id_with_len("$ -> %", 40), ""); + assert_eq!(git_branch_id_with_len("🔥🔥", 40), ""); + } }