diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index c5c1c345318b6f88c59ba2886507324e83d36ad3..0f1cd3ebf0fdf1df939ccc6f2b0d1a40545bf082 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -67,7 +67,6 @@ use language_model::{ConfigurationError, LanguageModelRegistry}; use project::project_settings::ProjectSettings; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; -use rand::Rng as _; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; @@ -2042,21 +2041,6 @@ impl AgentPanel { } } - fn generate_agent_branch_name() -> String { - let mut rng = rand::rng(); - let id: String = (0..8) - .map(|_| { - let idx: u8 = rng.random_range(0..36); - if idx < 10 { - (b'0' + idx) as char - } else { - (b'a' + idx - 10) as char - } - }) - .collect(); - format!("agent-{id}") - } - /// Partitions the project's visible worktrees into git-backed repositories /// and plain (non-git) paths. Git repos will have worktrees created for /// them; non-git paths are carried over to the new workspace as-is. @@ -2256,8 +2240,6 @@ impl AgentPanel { self.worktree_creation_status = Some(WorktreeCreationStatus::Creating); cx.notify(); - let branch_name = Self::generate_agent_branch_name(); - let (git_repos, non_git_paths) = self.classify_worktrees(cx); if git_repos.is_empty() { @@ -2269,28 +2251,18 @@ impl AgentPanel { return; } + // Kick off branch listing as early as possible so it can run + // concurrently with the remaining synchronous setup work. + let branch_receivers: Vec<_> = git_repos + .iter() + .map(|repo| repo.update(cx, |repo, _cx| repo.branches())) + .collect(); + let worktree_directory_setting = ProjectSettings::get_global(cx) .git .worktree_directory .clone(); - let (creation_infos, path_remapping) = match Self::start_worktree_creations( - &git_repos, - &branch_name, - &worktree_directory_setting, - cx, - ) { - Ok(result) => result, - Err(err) => { - self.set_worktree_creation_error( - format!("Failed to validate worktree directory: {err}").into(), - window, - cx, - ); - return; - } - }; - let (dock_structure, open_file_paths) = self .workspace .upgrade() @@ -2307,6 +2279,63 @@ impl AgentPanel { .downcast::(); let task = cx.spawn_in(window, async move |this, cx| { + // Await the branch listings we kicked off earlier. + let mut existing_branches = Vec::new(); + for result in futures::future::join_all(branch_receivers).await { + match result { + Ok(Ok(branches)) => { + for branch in branches { + existing_branches.push(branch.name().to_string()); + } + } + Ok(Err(err)) => { + Err::<(), _>(err).log_err(); + } + Err(_) => {} + } + } + + let existing_branch_refs: Vec<&str> = + existing_branches.iter().map(|s| s.as_str()).collect(); + let mut rng = rand::rng(); + let branch_name = + match crate::branch_names::generate_branch_name(&existing_branch_refs, &mut rng) { + Some(name) => name, + None => { + this.update_in(cx, |this, window, cx| { + this.set_worktree_creation_error( + "Failed to generate a branch name: all typewriter names are taken" + .into(), + window, + cx, + ); + })?; + return anyhow::Ok(()); + } + }; + + let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| { + Self::start_worktree_creations( + &git_repos, + &branch_name, + &worktree_directory_setting, + cx, + ) + }) { + Ok(Ok(result)) => result, + Ok(Err(err)) | Err(err) => { + this.update_in(cx, |this, window, cx| { + this.set_worktree_creation_error( + format!("Failed to validate worktree directory: {err}").into(), + window, + cx, + ); + }) + .log_err(); + return anyhow::Ok(()); + } + }; + let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await { Ok(paths) => paths, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 58a8edca779daa50862549058a0068e2ddb7c5bf..5ae2d677ba6dd4622127b39938f2bf005e7fcab9 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -3,6 +3,7 @@ mod agent_diff; mod agent_model_selector; mod agent_panel; mod agent_registry_ui; +mod branch_names; mod buffer_codegen; mod completion_provider; mod config_options; diff --git a/crates/agent_ui/src/branch_names.rs b/crates/agent_ui/src/branch_names.rs new file mode 100644 index 0000000000000000000000000000000000000000..74e3dbc76b729309403606dfbecc8ea87f271913 --- /dev/null +++ b/crates/agent_ui/src/branch_names.rs @@ -0,0 +1,847 @@ +use collections::HashSet; +use rand::Rng; + +/// Names of historical typewriter brands, for use in auto-generated branch names. +/// (Hyphens and parens have been dropped so that the branch names are one-word.) +/// +/// Thanks to https://typewriterdatabase.com/alph.0.brands for the names! +const TYPEWRITER_NAMES: &[&str] = &[ + "abeille", + "acme", + "addo", + "adler", + "adlerette", + "adlerita", + "admiral", + "agamli", + "agar", + "agidel", + "agil", + "aguia", + "aguila", + "ahram", + "aigle", + "ajax", + "aktiv", + "ala", + "alba", + "albus", + "alexander", + "alexis", + "alfa", + "allen", + "alonso", + "alpina", + "amata", + "amaya", + "amka", + "anavi", + "anderson", + "andina", + "antares", + "apex", + "apsco", + "aquila", + "archo", + "ardita", + "argyle", + "aristocrat", + "aristokrat", + "arlington", + "armstrong", + "arpha", + "artus", + "astoria", + "atlantia", + "atlantic", + "atlas", + "augusta", + "aurora", + "austro", + "automatic", + "avanti", + "avona", + "azzurra", + "bajnok", + "baldwin", + "balkan", + "baltica", + "baltimore", + "barlock", + "barr", + "barrat", + "bartholomew", + "bashkiriya", + "bavaria", + "beaucourt", + "beko", + "belka", + "bennett", + "bennington", + "berni", + "bianca", + "bijou", + "bing", + "bisei", + "biser", + "bluebird", + "bolida", + "borgo", + "boston", + "boyce", + "bradford", + "brandenburg", + "brigitte", + "briton", + "brooks", + "brosette", + "buddy", + "burns", + "burroughs", + "byron", + "calanda", + "caligraph", + "cappel", + "cardinal", + "carissima", + "carlem", + "carlton", + "carmen", + "cawena", + "cella", + "celtic", + "century", + "champignon", + "cherryland", + "chevron", + "chicago", + "cicero", + "cifra", + "citizen", + "claudia", + "cleveland", + "clover", + "coffman", + "cole", + "columbia", + "commercial", + "companion", + "concentra", + "concord", + "concordia", + "conover", + "constanta", + "consul", + "conta", + "contenta", + "contimat", + "contina", + "continento", + "cornelia", + "coronado", + "cosmopolita", + "courier", + "craftamatic", + "crandall", + "crown", + "culema", + "dactyle", + "dankers", + "dart", + "daugherty", + "davis", + "dayton", + "dea", + "delmar", + "densmore", + "depantio", + "diadema", + "dial", + "diamant", + "diana", + "dictatype", + "diplomat", + "diskret", + "dolfus", + "dollar", + "domus", + "drake", + "draper", + "duplex", + "durabel", + "dynacord", + "eagle", + "eclipse", + "edelmann", + "edelweiss", + "edison", + "edita", + "edland", + "efka", + "eldorado", + "electa", + "electromatic", + "elektro", + "elgin", + "elliot", + "emerson", + "emka", + "emona", + "empire", + "engadine", + "engler", + "erfurt", + "erika", + "esko", + "essex", + "eureka", + "europa", + "everest", + "everlux", + "excelsior", + "express", + "fabers", + "facit", + "fairbanks", + "faktotum", + "famos", + "federal", + "felio", + "fidat", + "filius", + "fips", + "fish", + "fitch", + "fleet", + "florida", + "flott", + "flyer", + "flying", + "fontana", + "ford", + "forto", + "fortuna", + "fox", + "framo", + "franconia", + "franklin", + "friden", + "frolio", + "furstenberg", + "galesburg", + "galiette", + "gallia", + "garbell", + "gardner", + "geka", + "generation", + "genia", + "geniatus", + "gerda", + "gisela", + "glashutte", + "gloria", + "godrej", + "gossen", + "gourland", + "grandjean", + "granta", + "granville", + "graphic", + "gritzner", + "groma", + "guhl", + "guidonia", + "gundka", + "hacabo", + "haddad", + "halberg", + "halda", + "hall", + "hammond", + "hammonia", + "hanford", + "hansa", + "harmony", + "harris", + "hartford", + "hassia", + "hatch", + "heady", + "hebronia", + "hebros", + "hega", + "helios", + "helma", + "herald", + "hercules", + "hermes", + "herold", + "heros", + "hesperia", + "hogar", + "hooven", + "hopkins", + "horton", + "hugin", + "hungaria", + "hurtu", + "iberia", + "idea", + "ideal", + "imperia", + "impo", + "industria", + "industrio", + "ingersoll", + "international", + "invicta", + "irene", + "iris", + "iskra", + "ivitsa", + "ivriah", + "jackson", + "janalif", + "janos", + "jolux", + "juki", + "junior", + "juventa", + "juwel", + "kamkap", + "kamo", + "kanzler", + "kappel", + "karli", + "karstadt", + "keaton", + "kenbar", + "keystone", + "kim", + "klein", + "kneist", + "knoch", + "koh", + "kolibri", + "kolumbus", + "komet", + "kondor", + "koniger", + "konryu", + "kontor", + "kosmopolit", + "krypton", + "lambert", + "lasalle", + "lectra", + "leframa", + "lemair", + "lemco", + "liberty", + "libia", + "liga", + "lignose", + "lilliput", + "lindeteves", + "linowriter", + "listvitsa", + "ludolf", + "lutece", + "luxa", + "lyubava", + "mafra", + "magnavox", + "maher", + "majestic", + "majitouch", + "manhattan", + "mapuua", + "marathon", + "marburger", + "maritsa", + "maruzen", + "maskelyne", + "masspro", + "matous", + "mccall", + "mccool", + "mcloughlin", + "mead", + "mechno", + "mehano", + "meiselbach", + "melbi", + "melior", + "melotyp", + "mentor", + "mepas", + "mercedesia", + "mercurius", + "mercury", + "merkur", + "merritt", + "merz", + "messa", + "meteco", + "meteor", + "micron", + "mignon", + "mikro", + "minerva", + "mirian", + "mirina", + "mitex", + "molle", + "monac", + "monarch", + "mondiale", + "monica", + "monofix", + "monopol", + "monpti", + "monta", + "montana", + "montgomery", + "moon", + "morgan", + "morris", + "morse", + "moya", + "moyer", + "munson", + "musicwriter", + "nadex", + "nakajima", + "neckermann", + "neubert", + "neya", + "ninety", + "nisa", + "noiseless", + "noor", + "nora", + "nord", + "norden", + "norica", + "norma", + "norman", + "north", + "nototyp", + "nova", + "novalevi", + "odell", + "odhner", + "odo", + "odoma", + "ohio", + "ohtani", + "oliva", + "oliver", + "olivetti", + "olympia", + "omega", + "optima", + "orbis", + "orel", + "orga", + "oriette", + "orion", + "orn", + "orplid", + "pacior", + "pagina", + "parisienne", + "passat", + "pearl", + "peerless", + "perfect", + "perfecta", + "perkeo", + "perkins", + "perlita", + "pettypet", + "phoenix", + "piccola", + "picht", + "pinnock", + "pionier", + "plurotyp", + "plutarch", + "pneumatic", + "pocket", + "polyglott", + "polygraph", + "pontiac", + "portable", + "portex", + "pozzi", + "premier", + "presto", + "primavera", + "progress", + "protos", + "pterotype", + "pullman", + "pulsatta", + "quick", + "racer", + "radio", + "rally", + "rand", + "readers", + "reed", + "referent", + "reff", + "regent", + "regia", + "regina", + "rekord", + "reliable", + "reliance", + "remagg", + "rembrandt", + "remer", + "remington", + "remsho", + "remstar", + "remtor", + "reporters", + "resko", + "rex", + "rexpel", + "rheinita", + "rheinmetall", + "rival", + "roberts", + "robotron", + "rocher", + "rochester", + "roebuck", + "rofa", + "roland", + "rooy", + "rover", + "roxy", + "roy", + "royal", + "rundstatler", + "sabaudia", + "sabb", + "saleem", + "salter", + "sampo", + "sarafan", + "saturn", + "saxonia", + "schade", + "schapiro", + "schreibi", + "scripta", + "sears", + "secor", + "selectric", + "selekta", + "senator", + "sense", + "senta", + "serd", + "shilling", + "shimade", + "shimer", + "sholes", + "shuang", + "siegfried", + "siemag", + "silma", + "silver", + "simplex", + "simtype", + "singer", + "smith", + "soemtron", + "sonja", + "speedwriter", + "sphinx", + "starlet", + "stearns", + "steel", + "stella", + "steno", + "sterling", + "stoewer", + "stolzenberg", + "stott", + "strangfeld", + "sture", + "stylotyp", + "sun", + "superba", + "superia", + "supermetall", + "surety", + "swintec", + "swissa", + "talbos", + "talleres", + "tatrapoint", + "taurus", + "taylorix", + "tell", + "tempotype", + "tippco", + "titania", + "tops", + "towa", + "toyo", + "tradition", + "transatlantic", + "traveller", + "trebla", + "triumph", + "turia", + "typatune", + "typen", + "typorium", + "ugro", + "ultima", + "unda", + "underwood", + "unica", + "unitype", + "ursula", + "utax", + "varityper", + "vasanta", + "vendex", + "venus", + "victor", + "victoria", + "video", + "viking", + "vira", + "virotyp", + "visigraph", + "vittoria", + "volcan", + "vornado", + "voss", + "vultur", + "waltons", + "wanamaker", + "wanderer", + "ward", + "warner", + "waterloo", + "waverley", + "wayne", + "webster", + "wedgefield", + "welco", + "wellington", + "wellon", + "weltblick", + "westphalia", + "wiedmer", + "williams", + "wilson", + "winkel", + "winsor", + "wizard", + "woodstock", + "woodwards", + "yatran", + "yost", + "zenit", + "zentronik", + "zeta", + "zeya", +]; + +/// Picks a typewriter name that isn't already taken by an existing branch. +/// +/// Each entry in `existing_branches` is expected to be a full branch name +/// like `"olivetti-a3f9b2c1"`. The prefix before the last `'-'` is treated +/// as the taken typewriter name. Branches without a `'-'` are ignored. +/// +/// Returns `None` when every name in the pool is already taken. +pub fn pick_typewriter_name( + existing_branches: &[&str], + rng: &mut impl Rng, +) -> Option<&'static str> { + let disallowed: HashSet<&str> = existing_branches + .iter() + .filter_map(|branch| branch.rsplit_once('-').map(|(prefix, _)| prefix)) + .collect(); + + let available: Vec<&'static str> = TYPEWRITER_NAMES + .iter() + .copied() + .filter(|name| !disallowed.contains(name)) + .collect(); + + if available.is_empty() { + return None; + } + + let index = rng.random_range(0..available.len()); + Some(available[index]) +} + +/// Generates a branch name like `"olivetti-a3f9b2c1"` by picking a typewriter +/// name that isn't already taken and appending an 8-character alphanumeric hash. +/// +/// Returns `None` when every typewriter name in the pool is already taken. +pub fn generate_branch_name(existing_branches: &[&str], rng: &mut impl Rng) -> Option { + let typewriter_name = pick_typewriter_name(existing_branches, rng)?; + let hash: String = (0..8) + .map(|_| { + let idx: u8 = rng.random_range(0..36); + if idx < 10 { + (b'0' + idx) as char + } else { + (b'a' + idx - 10) as char + } + }) + .collect(); + Some(format!("{typewriter_name}-{hash}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::StdRng; + + #[gpui::test(iterations = 10)] + fn test_pick_typewriter_name_with_no_disallowed(mut rng: StdRng) { + let name = pick_typewriter_name(&[], &mut rng); + assert!(name.is_some()); + assert!(TYPEWRITER_NAMES.contains(&name.unwrap())); + } + + #[gpui::test(iterations = 10)] + fn test_pick_typewriter_name_excludes_taken_names(mut rng: StdRng) { + let branch_names = &["olivetti-abc12345", "selectric-def67890"]; + let name = pick_typewriter_name(branch_names, &mut rng).unwrap(); + assert_ne!(name, "olivetti"); + assert_ne!(name, "selectric"); + } + + #[gpui::test] + fn test_pick_typewriter_name_all_taken(mut rng: StdRng) { + let branch_names: Vec = TYPEWRITER_NAMES + .iter() + .map(|name| format!("{name}-00000000")) + .collect(); + let branch_name_refs: Vec<&str> = branch_names.iter().map(|s| s.as_str()).collect(); + let name = pick_typewriter_name(&branch_name_refs, &mut rng); + assert!(name.is_none()); + } + + #[gpui::test(iterations = 10)] + fn test_pick_typewriter_name_ignores_branches_without_hyphen(mut rng: StdRng) { + let branch_names = &["main", "develop", "feature"]; + let name = pick_typewriter_name(branch_names, &mut rng); + assert!(name.is_some()); + assert!(TYPEWRITER_NAMES.contains(&name.unwrap())); + } + + #[gpui::test(iterations = 10)] + fn test_generate_branch_name_format(mut rng: StdRng) { + let branch_name = generate_branch_name(&[], &mut rng).unwrap(); + let (prefix, suffix) = branch_name.rsplit_once('-').unwrap(); + assert!(TYPEWRITER_NAMES.contains(&prefix)); + assert_eq!(suffix.len(), 8); + assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric())); + } + + #[gpui::test] + fn test_generate_branch_name_returns_none_when_exhausted(mut rng: StdRng) { + let branch_names: Vec = TYPEWRITER_NAMES + .iter() + .map(|name| format!("{name}-00000000")) + .collect(); + let branch_name_refs: Vec<&str> = branch_names.iter().map(|s| s.as_str()).collect(); + let result = generate_branch_name(&branch_name_refs, &mut rng); + assert!(result.is_none()); + } + + #[gpui::test(iterations = 100)] + fn test_generate_branch_name_never_reuses_taken_prefix(mut rng: StdRng) { + let existing = &["olivetti-123abc", "selectric-def456"]; + let branch_name = generate_branch_name(existing, &mut rng).unwrap(); + let (prefix, _) = branch_name.rsplit_once('-').unwrap(); + assert_ne!(prefix, "olivetti"); + assert_ne!(prefix, "selectric"); + } + + #[gpui::test(iterations = 100)] + fn test_generate_branch_name_avoids_multiple_taken_prefixes(mut rng: StdRng) { + let existing = &[ + "olivetti-aaa11111", + "selectric-bbb22222", + "corona-ccc33333", + "remington-ddd44444", + "underwood-eee55555", + ]; + let taken_prefixes: HashSet<&str> = existing + .iter() + .filter_map(|b| b.rsplit_once('-').map(|(prefix, _)| prefix)) + .collect(); + let branch_name = generate_branch_name(existing, &mut rng).unwrap(); + let (prefix, _) = branch_name.rsplit_once('-').unwrap(); + assert!( + !taken_prefixes.contains(prefix), + "generated prefix {prefix:?} collides with an existing branch" + ); + } + + #[gpui::test(iterations = 100)] + fn test_generate_branch_name_with_varied_hash_suffixes(mut rng: StdRng) { + let existing = &[ + "olivetti-aaaaaaaa", + "olivetti-bbbbbbbb", + "olivetti-cccccccc", + ]; + let branch_name = generate_branch_name(existing, &mut rng).unwrap(); + let (prefix, _) = branch_name.rsplit_once('-').unwrap(); + assert_ne!( + prefix, "olivetti", + "should avoid olivetti regardless of how many variants exist" + ); + } + + #[test] + fn test_typewriter_names_are_valid() { + let mut seen = HashSet::default(); + for &name in TYPEWRITER_NAMES { + assert!( + seen.insert(name), + "duplicate entry in TYPEWRITER_NAMES: {name:?}" + ); + } + + for window in TYPEWRITER_NAMES.windows(2) { + assert!( + window[0] <= window[1], + "TYPEWRITER_NAMES is not sorted: {0:?} should come after {1:?}", + window[1], + window[0], + ); + } + + for &name in TYPEWRITER_NAMES { + assert!( + !name.contains('-'), + "TYPEWRITER_NAMES entry contains a hyphen: {name:?}" + ); + } + + for &name in TYPEWRITER_NAMES { + assert!( + name.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()), + "TYPEWRITER_NAMES entry is not lowercase: {name:?}" + ); + } + } +} diff --git a/typos.toml b/typos.toml index c4e326359dec6e2a47861df1aab7b66f0644d7a3..863fea3822d62a51f737c3d7fa87a4c198710cfa 100644 --- a/typos.toml +++ b/typos.toml @@ -4,6 +4,9 @@ ignore-hidden = false extend-exclude = [ ".git/", + # Typewriter model names used for agent branch names aren't typos. + "crates/agent_ui/src/branch_names.rs", + # Contributor names aren't typos. ".mailmap",